Software Teardowns: Console.WriteLine (Part 1: Windows)

I love teardowns, and I spend a lot of time looking at the articles that tear down different consumer products to show how they’re made. Software teardowns are harder though. It’s usually difficult to figure out a discrete piece to look at and tear it down without a whole lot of problems. Or at least, that’s what I’ve always thought.

Today, I’m going to take a look at the Console.WriteLine method in .NET Core; and attempt to ‘tear it down’. I’ll take it as far as I can, and we’ll see where I get. I haven’t ‘pre-planned’ this (though I have trawled through the .NET Core source code before on an unrelated issue, and am reasonably sure this won’t suck). I’ll attempt to stick to just the act of writing a character to the console (and printing a new line); but I may see other things I want to talk about along the way. Ado aside, here we go.

The source code for the .NET Core framework (called corefx by whoever names these things) can be found under the .NET Foundation’s github page, here. The .NET Core Framework (I’ll just call it corefx or The Framework from here on out) is meant to be a managed library of pre-built classes and features you can use as a programmer. You’ll hear “Standard Library” thrown about in various languages; corefx is .NET Core’s Standard Library. You could create your own if you wanted to (the Mono team did); but generally unless there are licensing concerns, don’t re-invent the wheel. A note about licensing, corefx is MIT licensed; so there shouldn’t be any licensing issues. (You can always find licensing information in each source code file, as well as a ‘repo-level’ LICENSE.MD, LICENSE, or LICENSE.TXT file or some such. CoreFx’s license is here.

For this post I’ll be referring to this revision of the framework. It’s currently ‘master’ as well; but master is nebelous as to whatever’s checked in; and I want to be able to point to specific pieces that may change in the future.

The first thing of note is that The Framework makes use of several third-party libraries and source code(s) (todo: figure out the plural of source code: “source codes”, “sources code” or “source code”), and they spell out a notice here as to what they use and where it’s from. Several interesting tidbits, including “Bit Twiddling Hacks”, “decode_fuzzer.c” and Brotli. I have no idea what Brotli is; but I’m curious to know.

That aside, the central question I have is:

Given this simple program; what is the resulting look of the code that prints characters to the console that gets used on Windows? On Debian-Based Linux Machines? (I’m not as interested in the compiled IL right now).

using System;

public class Program {
    public static void Main(string[] args) {
      Console.WriteLine("Hello World!");

Because I am importing System, I would expect the Console class to be right underneath System; and lo and behold it is.

The source code structure surrounding it is interesting.

If I click on src, underneath corefx, I see a whole bunch of namespaces that I typically use in applications:

well these look familiar…

The one at the top is interesting; it’s called Common. Even Microsoft can’t get away from a dumper folder, eh?

It looks like at least part of the Common is a sync-ing point between projects, at least it says that on the tin.

Upon further inspection, due to the myriad of internal classes (I’ve spot-checked and all are marked with the internal identifier), I’d classify this code as necessary to build/develop for the .NET Framework; but not necessary for consumers of the .NET Framework.

That detour finished; I click on System.Console and open it up to inspect what’s going on. There shouldn’t be that much here, right?

The first thing I notice is that System.Console has its own .sln file; so presumably if we ever need to make changes to System.Console, you don’t have to load the entire .NET Framework source. This is probably a recurring theme; but interesting to note.

This ends up being a common theme. .sln, .props, ref, src, and tests… I wonder if it’s code-gen’d?

The second thing is there is a ref folder, and it contains a partial Console class; with all of the property and method signatures needed for this class. It has no implementation, however. I’m not really sure why it’s here; but to guess (way out of my element, mind you); would seem it could be there so someone could check API Compatibility without needing the whole framework source. As I said, I have no idea; and I’ll have to google more to figure out why it exists. Interestingly enough there’s a short-link at the top of this file that points to the API Design guidelines for third-parties to request their library/class be made as part of the System.* namespace. They even include an example of a recent process to add classes to the namespace. It is worth a read.

In the ref folder, besides the partial definition of the System.Console class; there are also Configuration.props files and a System.Console.csproj file.

I really have no idea what “ref” is for but it looks important.

It still uses the non-SDK tooling (which isn’t surprising — but it makes you wonder — if Microsoft can’t use their own SDK tooling for the .NET Core Framework Library; what’s the hope for the rest of us?). (Editor’s note, I wrote this at the beginning of 2018; 1 year later, it still uses the non-SDK tooling).

I assume the .props file is to give some hints as to how to build this; there are two interesting build properties that I haven’t seen elsewhere (yet):


IsNETCoreApp is used here, and it (unsurprisingly) is used during building the Framework, and is used to reduce the build to only projects that are a part of .NET Core.

IsUAP is an acronym I hadn’t heard before; and a quick Googling suggests it’s the same as UWP (Universal Windows Program). Not sure if this is a marketing change, or if UAP actually means something different than UWP. I thought UWP was The Right Acronym For This Thing (TRAFTT); and it appears that UAP is an earlier naming. Considering the build tooling isn’t using the latest; they probably haven’t gotten around to the name change yet (and who would want to?)

Now that Github has stopped rate limiting my searches, I can go back to explaining what is going on here. I should probably switch over to grep; but I’m trying to keep this lightweight. You know, besides all the commentary.

IsUAP is slightly more interesting; as it also has runtime considerations. It’s used during building to determine where to put the bin folder but it’s also used by test code to ‘do things differently’ depending on if it’s UAP or not. It looks like UAP uses a WinRTWebSocket, and non-UAP uses WinHttpWebSockets. I’m literally monkey-typing what I see here, and there are lots more questions than answers, but there it is. There are several places where UAP differs from non-UAP; I’m just highlighting one.

The tests for System.Console are interesting. Anything that tests the Console is by definition an integrated test; so I’m cracking these to see how they delineate Unit Tests from Integration Tests.

Interestingly (I know, I know, I use that word too much), it appears that in order to test the System.Console class; The Framework authors wrote a helper class to intercept what is sent to the console and look at the MemoryStream, instead of it actually getting sent to the Console.

It also appears there’s a ManualTests class to actually have a user test the console.

I have lots of questions that I’d probably get answered by running the tests themselves (but that’s not why I’m here today).

Now to the meat of what I wanted to talk about; the Console class itself.

It appears the Console has its class, and then there are UnixPal and WindowsPal classes that actually act as implementations for particular platforms (Windows, Unix). I’m guessing “PAL” stands for Platform Abstraction Layer; but again, that’s a guess. A quick Google Search reveals even more goodies, a Github project I never knew existed!

The System.Console.csproj file contains build switches to determine which ConsolePal class to bring in. The ConsolePal.<platform>.cs class is an internal class; and purely a drop-in replacement for implementations on different platforms.

The class sends hints to the compiler not to inline the methods; this appears to be done for ease-of-debugging reasons:

        // Give a hint to the code generator to not inline the common console methods. The console methods are 
        // not performance critical. It is unnecessary code bloat to have them inlined.
        // Moreover, simple repros for codegen bugs are often console-based. It is tedious to manually filter out 
        // the inlined console writelines from them.

The particular overload of WriteLine I’m concerned with is this one:

public static void WriteLine(String value) 

Out in this context refers to a public member that is of type TextWriter.

public static TextWriter Out => EnsureInitialized(ref s_out, () => CreateOutputWriter(OpenStandardOutput()));

s_out refers to a private TextWriter member, and that is passed to EnsureInitialized along with a Func<T>, and EnsureInitalized has the following definition:

internal static T EnsureInitialized<T>(ref T field, Func<T> initializer) where T : class =>
            LazyInitializer.EnsureInitialized(ref field, ref InternalSyncObject, initializer);

Since it’s turtles all the way down, the EnsureInitialized in turn calls a LazyInitializer class (which probably follows the Lazy Initialization (aka, Lazy Loading) pattern.

This LazyInitializer class is written to be Thread Safe, and overall if you’re interested in “How would I lazily initialize members in a high-performance situation”, the code for this class is worth a look. Upon further inspection (My eyes glazed over the public modifier); it looks like you can use this in your code (so that’s cool — and I’ll have to try this).

The next part of the EnsureInitialized method call for Out is the CreateOutputWriter(OpenStandardOutput()) method. Briefly, it does the following:

public static Stream OpenStandardOutput()
    return ConsolePal.OpenStandardOutput();

OpenStandardOutput() is an abstraction over the system specific method of opening Standard Output,

And CreateOutputWriter does the following:

private static TextWriter CreateOutputWriter(Stream outputStream)
    return SyncTextWriter.GetSynchronizedTextWriter(outputStream == Stream.Null ?
        StreamWriter.Null :
        new StreamWriter(
            stream: outputStream,
            encoding: new ConsoleEncoding(OutputEncoding), // This ensures no prefix is written to the stream.
            bufferSize: DefaultConsoleBufferSize,
            leaveOpen: true) { AutoFlush = true });

It’s important to note that the outputStream that is a parameter is the previous ConsolePal.OpenStandardOutput() method; so we’ll examine that further when we break off into the Windows vs. Unix implementations.
At this point, I’m going to go down two paths; what Console.WriteLine does on Windows in this blog post, and Unix in another blog post.


The first step as we saw above was to OpenStandardOutput(), and for Windows that method can be found here:

public static Stream OpenStandardOutput()
    return GetStandardFile(Interop.Kernel32.HandleTypes.STD_OUTPUT_HANDLE, FileAccess.Write);

It returns a Stream; and makes a call to GetStandardFile with two parameters.


From my C days (I’m still a junior C developer, mind you), I can guess that STD_OUTPUT_HANDLE is a constant, and navigating to Interop.Kernel32.HandleTypes confirms it:

internal partial class Interop
    internal partial class Kernel32
        internal partial class HandleTypes
            internal const int STD_INPUT_HANDLE = -10;
            internal const int STD_OUTPUT_HANDLE = -11;
            internal const int STD_ERROR_HANDLE = -12;

The internal tells me this is for the Framework itself (not us), and it being a partial class tells me there will be another section of this class elsewhere. I can’t seem to find it (again, I’m basing it off of source code available via github, and not having it built locally), so I have unanswered questions as to what is this for and why it is partial.

STD_OUTPUT_HANDLE is a constant that is set to -11, so that value has meaning for GetStandardFile (likely it means that Win32 has hardcoded outputing to StdOut as -11).

FileAccess.Write is an Enum Flag that is a clean way to tell the underlying Win32 libraries that we want to ‘write’ to the Console Output “stream”.

As an archeological note, it appears that the System.IO.FileSystem.Primitives project that FileAccess sits in uses an older version of .NET Core, as evidenced by its use of project.json.

The method declaration for GetStandardFile is as follows:

private static Stream GetStandardFile(int handleType, FileAccess access)
    IntPtr handle = Interop.Kernel32.GetStdHandle(handleType);

    // If someone launches a managed process via CreateProcess, stdout,
    // stderr, & stdin could independently be set to INVALID_HANDLE_VALUE.
    // Additionally they might use 0 as an invalid handle.  We also need to
    // ensure that if the handle is meant to be writable it actually is.
    if (handle == IntPtr.Zero || handle == s_InvalidHandleValue ||
        (access != FileAccess.Read && !ConsoleHandleIsWritable(handle)))
        return Stream.Null;

    return new WindowsConsoleStream(handle, access, GetUseFileAPIs(handleType));

Let’s break this down.

IntPtr is an integer sized pointer struct; and it’s used to get a pointer to the handle we’ll be writing to.

Whether it’s a 32-bit or 64-bit size is dependent on a compiler flag:

#if BIT64
using nint = System.Int64;
using nint = System.Int32;

I have multiple questions here (having never seen the internals of this struct before), and I have to say most of this code is deep in the weeds code. To see which overload of this struct initializer is used, I’ll have to see what InteropKernel32.GetStdHandle(int) does:

using System;
using System.Runtime.InteropServices;

internal partial class Interop
    internal partial class Kernel32
        [DllImport(Libraries.Kernel32, SetLastError = true)]
        internal static extern IntPtr GetStdHandle(int nStdHandle);  // param is NOT a handle, but it returns one!

Ok. At this point we are making a call into the Win32 libraries; specifically the Libraries.Kernel32 library. I’m going to check to see if I can dig into the internals of the Libraries.Kernel32 class, but likely will not be able to (darn you, closed source). Yea, a quick Google Search indicates this is the end of the line for determining how it gets a handle. On the Unix side we’ll be able to dig deeper than this, but for Windows we’ll have to stop here on getting the handle. If you ever want to know about the internals of Windows, make sure you read The Old New Thing, it’s Raymond Chen’s blog; and is (with apologies to a certain comedian, quite amazing).

Once it gets the handle, it checks to make sure the handle is valid to write to:

// If someone launches a managed process via CreateProcess, stdout,
// stderr, & stdin could independently be set to INVALID_HANDLE_VALUE.
// Additionally they might use 0 as an invalid handle.  We also need to
// ensure that if the handle is meant to be writable it actually is.

The comment above is great; it doesn’t just restate what the code does; it tells why the code does what it does. If you’re going to write comments in code, be like this commenter.
And the code itself:

if (handle == IntPtr.Zero || handle == s_InvalidHandleValue ||
    (access != FileAccess.Read && !ConsoleHandleIsWritable(handle)))
    return Stream.Null;

IntPtr.Zero is likely a Windows-wide ‘null’ or sentinel value for a pointer.

s_InvalidHandleValue is by default:

private static IntPtr s_InvalidHandleValue = new IntPtr(-1);

and access cannot be ‘read’ only (read and write would be OK), and the ConsoleHandleIsWritable must be set to false.

This code gave me a headache for a minute because I realized this entire if statement is meant to be a guard clause; if any of these conditions exist, we can’t write and should therefore return Stream.Null. I can’t find System.IO.Stream (though as I understand it it is an abstract class); though it looks like CoreFX has moved it to the System.Runtime.Extensions namespace, and the cleanest definition I can find for Stream.Null is that it appears each child class of Stream (some or all) redefine it using the new operator:

public new static readonly StreamWriter Null = new StreamWriter(Stream.Null, UTF8NoBOM, MinBufferSize, true);

Before we pass the guard-clause, there’s one remaining method in the if statement: ConsoleHandleIsWriteable(IntPtr).

I’m pasting it below (with its comments) in its entirety; and the comments are A++ (would vote A+ again):

// Checks whether stdout or stderr are writable.  Do NOT pass
// stdin here! The console handles are set to values like 3, 7, 
// and 11 OR if you've been created via CreateProcess, possibly -1
// or 0.  -1 is definitely invalid, while 0 is probably invalid.
// Also note each handle can independently be invalid or good.
// For Windows apps, the console handles are set to values like 3, 7, 
// and 11 but are invalid handles - you may not write to them.  However,
// you can still spawn a Windows app via CreateProcess and read stdout
// and stderr. So, we always need to check each handle independently for validity
// by trying to write or read to it, unless it is -1.
private static unsafe bool ConsoleHandleIsWritable(IntPtr outErrHandle)
    // Windows apps may have non-null valid looking handle values for 
    // stdin, stdout and stderr, but they may not be readable or 
    // writable.  Verify this by calling WriteFile in the 
    // appropriate modes. This must handle console-less Windows apps.
    int bytesWritten;
    byte junkByte = 0x41;
    int r = Interop.Kernel32.WriteFile(outErrHandle, &junkByte, 0, out bytesWritten, IntPtr.Zero);
    return r != 0; // In Win32 apps w/ no console, bResult should be 0 for failure.

And here’s the signature of Interop.Kernel32.WriteFile():

 internal partial class Kernel32
    [DllImport(Libraries.Kernel32, SetLastError = true)]
    internal static extern unsafe int WriteFile(
        IntPtr handle,
        byte* bytes,
        int numBytesToWrite,
        out int numBytesWritten,
        IntPtr mustBeZero);

It looks like this is to make a system check to see if this is a writable location by actually trying to do it, and what I can ascertain (again, without seeing the Kernel32.WriteFile source) is that it tries to write zero bytes to the supplied handle, and the exit code from trying to run this function should be non-zero. (Which is interesting because 0 is generally success).

And if the guard clause is passed; the interesting part for us is now:

return new WindowsConsoleStream(handle, access, GetUseFileAPIs(handleType));

line (here).

Before diving into that class, I first want to resolve what GetUseFileAPIs does, so I’ll find that, and it turns out to be a private method in this class:

private static bool GetUseFileAPIs(int handleType)
    switch (handleType)
        case Interop.Kernel32.HandleTypes.STD_INPUT_HANDLE:
            return Console.InputEncoding.CodePage != Encoding.Unicode.CodePage || Console.IsInputRedirected;

        case Interop.Kernel32.HandleTypes.STD_OUTPUT_HANDLE:
            return Console.OutputEncoding.CodePage != Encoding.Unicode.CodePage || Console.IsOutputRedirected;

        case Interop.Kernel32.HandleTypes.STD_ERROR_HANDLE:
            return Console.OutputEncoding.CodePage != Encoding.Unicode.CodePage || Console.IsErrorRedirected;

            // This can never happen.
            Debug.Assert(false, "Unexpected handleType value (" + handleType + ")");
            return true;

while I applaud their confidence at the default state; I’ll keep the commentary on just the part of the case/switch we care about:

case Interop.Kernel32.HandleTypes.STD_OUTPUT_HANDLE:
    return Console.OutputEncoding.CodePage != Encoding.Unicode.CodePage || Console.IsOutputRedirected;

And now we’re into OutputEncoding, and particularly I don’t quite understand the ‘why’ behind this; but they want to make sure the OutputEncoding is unicode, or that the output is being redirected. If either of those things is true; then the intent is to use File APIs (and not the console?). I have questions behind this; but from what I can gather the Console API assumes Unicode and redirecting the output would mean it’s going to a file (typically).


I’m relatively sure at this point we’re nearing the “We’re about to print to the console” stage; (it’s in the name, right?). The WindowsConsoleStream is a private class that provides the ability to stream to the WindowsConsole.

The constructor for this class follows:

internal WindowsConsoleStream(IntPtr handle, FileAccess access, bool useFileAPIs) : base(access)
    Debug.Assert(handle != IntPtr.Zero && handle != s_InvalidHandleValue, "ConsoleStream expects a valid handle!");
    _handle = handle;
    _isPipe = Interop.Kernel32.GetFileType(handle) == Interop.Kernel32.FileTypes.FILE_TYPE_PIPE;
    _useFileAPIs = useFileAPIs;

The Debug.Assert is a nice touch (I’ll go into it more below); and the only new thing we haven’t seen before is the GetFileType for the handle to determine if we’re trying to pipe the output.

A quick Github search brings a set of constants (set to 0x0003):

and since it’s derived from ConsoleStream, here’s the constructor for that class as well:

internal ConsoleStream(FileAccess access)
    Debug.Assert(access == FileAccess.Read || access == FileAccess.Write);
    _canRead = ((access & FileAccess.Read) == FileAccess.Read);
    _canWrite = ((access & FileAccess.Write) == FileAccess.Write);

Nothing new here; it’s just setting an internal variable to whether we can write to the Console. Debug.Assert is rather interesting, and bears its own blog post; but in short: Debug.Assert is a throwback to C programming where invariants (things that if they happen mean something is fundamentally broken) were checked using a macro (because C), and that macro generally looked like ASSERT(ThingThatShouldNeverBeFalse) (the uppercase ASSERT was to let other programmers know it was a pre-processor macro). That practice carries on, except now, Debug.Assert is a real thing, with an implementation in the .NET Core Runtime.

There are several interesting parts here (and I refuse to let myself be sucked into them; but I’ll mention them nonetheless):

public static void Assert(bool condition, string message, string detailMessage)
    if (!condition)
        string stackTrace;

            stackTrace = Internal.Runtime.Augments.EnvironmentAugments.StackTrace;
            stackTrace = "";

        WriteLine(FormatAssert(stackTrace, message, detailMessage));
        s_ShowAssertDialog(stackTrace, message, detailMessage);

First is that it appears (without checking into consumers of the ConditionalAttribute attribute) that this code will only be compiled in to the Runtime on a “DEBUG” build (if the DEBUG switch is activated on the build?)

Second is that it looks (again, I’m guessing) that it produces a StackTrace up to the point where the DebugAssert was called, and then there is likely a platform specific s_ShowAssertDialog that is called to let the developer know something bombed.

Back to the fun. So it looks like once the WindowsConsoleStream class is instantiated, there are three things you can do; read, write, and flush.

We’ll focus on the Write part (though Flush is probably useful too).

public override void Write(byte[] buffer, int offset, int count)
    ValidateWrite(buffer, offset, count);

    int errCode = WriteFileNative(_handle, buffer, offset, count, _useFileAPIs);
    if (Interop.Errors.ERROR_SUCCESS != errCode)
        throw Win32Marshal.GetExceptionForWin32Error(errCode);

It calls ValidateWrite which ensures the values are correct:

protected void ValidateWrite(byte[] buffer, int offset, int count)
    if (buffer == null)
        throw new ArgumentNullException(nameof(buffer));
    if (offset < 0 || count < 0)
        throw new ArgumentOutOfRangeException(offset < 0 ? nameof(offset) : nameof(count), SR.ArgumentOutOfRange_NeedNonNegNum);
    if (buffer.Length - offset < count)
        throw new ArgumentException(SR.Argument_InvalidOffLen);

    if (!_canWrite) throw Error.GetWriteNotSupported();

and then it calls a private internal method called WriteFileNative that uses that _useFileAPIsboolean to determine which APIs need to be used (Screen or writing to a file)

WriteFileNative gets us to the guts of what happens. Let’s take this a section at a time.

private static unsafe int WriteFileNative(IntPtr hFile, byte[] bytes, int offset, int count, bool useFileAPIs)
    Debug.Assert(offset >= 0, "offset >= 0");
    Debug.Assert(count >= 0, "count >= 0");
    Debug.Assert(bytes != null, "bytes != null");
    Debug.Assert(bytes.Length >= offset + count, "bytes.Length >= offset + count");

First, it’s an unsafe method (likely because it’s touching things outside the framework), and the Debug.Asserts rear their heads again.

// You can't use the fixed statement on an array of length 0.
if (bytes.Length == 0)
    return Interop.Errors.ERROR_SUCCESS;

Return ERROR_SUCCESS. That’s good, right? I don’t know what the fixed statement is; but I guess we’ll get to that in a minute.

bool writeSuccess;
fixed (byte* p = &bytes[0])
    if (useFileAPIs)
        int numBytesWritten;
        writeSuccess = (0 != Interop.Kernel32.WriteFile(hFile, p + offset, count, out numBytesWritten, IntPtr.Zero));
        // In some cases we have seen numBytesWritten returned that is twice count;
        // so we aren't asserting the value of it. See corefx #24508

Ok, that was quick. A google search tells me the C# keyword fixedis only used in unsafecode to tell the runtime the position of the memory should not be moved. The fixed keyword allows us to do pointer arithmetic in C# without the runtime mettling in our affairs.

This block of code also points out there’s an interesting bug in the framework where the number of bytes written doubles? Or maybe it’s counting characters when it should be counting bytes? Who knows?


        // If the code page could be Unicode, we should use ReadConsole instead, e.g.
        // Note that WriteConsoleW has a max limit on num of chars to write (64K)
        // []
        // However, we do not need to worry about that because the StreamWriter in Console has
        // a much shorter buffer size anyway.
        int charsWritten;
        writeSuccess = Interop.Kernel32.WriteConsole(hFile, p + offset, count / BytesPerWChar, out charsWritten, IntPtr.Zero);
        Debug.Assert(!writeSuccess || count / BytesPerWChar == charsWritten);
if (writeSuccess)
    return Interop.Errors.ERROR_SUCCESS;

// For pipes that are closing or broken, just stop.
// (E.g. ERROR_NO_DATA ("pipe is being closed") is returned when we write to a console that is closing;
// ERROR_BROKEN_PIPE ("pipe was closed") is returned when stdin was closed, which is not an error, but EOF.)
int errorCode = Marshal.GetLastWin32Error();
if (errorCode == Interop.Errors.ERROR_NO_DATA || errorCode == Interop.Errors.ERROR_BROKEN_PIPE)
    return Interop.Errors.ERROR_SUCCESS;
return errorCode;

If we aren’t using the FileAPIs, then call the Kernel32’s WriteConsole function with the appropriate handle, the current location of the pointer, how many characters we’re writing (count / BytesPerWChar ), a placeholder out variable that will contain what’s been written, and IntPtr.Zero. The final piece is to ensure that if the pipe is closed, then stop trying to write to the console.

Overall, this was a very interesting dive into the .NET Core framework. I’m a bit bummed that I couldn’t see more of what Windows does to actually print a character to the screen; but I feel like I’ll be able to do that in the next blog post, when we dive into how the .NET Core Framework prints to a Unix console.

3 thoughts on “Software Teardowns: Console.WriteLine (Part 1: Windows)”

  1. The ref folder is probably used to produce the reference assemblies (assemblies with metadata but no implementation, used as reference targets at compile time)

    > 1 year later, it still uses the non-SDK tooling

    No, it doesn’t… maybe it did when you wrote the article, but your link points to a SDK-style project now 😉

Leave a Reply