Tutorial 89: Extending CNA with sharp-runtime

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • What sharp-runtime is, and the deliberate limits of its .NET subset.
  • The two include roots, System/ and SharpRuntime/, and what each holds.
  • The primitive type aliases, and where you meet them in CNA signatures.
  • System::TimeSpan, System::IDisposable, collections, System::IO and JSON.

Before you start — Tutorial 01: Introduction to CNA (it introduces sharp-runtime) and Tutorial 71: Memory Management in C++ (the IDisposable pattern in practice).

What sharp-runtime actually is

sharp-runtime is a separate C++23 project that reimplements a subset of the .NET Base Class Library in idiomatic C++. It is not a small helper header: it builds as independently selectable CMake components (its generated component catalogue lists 47 registered components over 44 physical modules, plus the All aggregate; the README still says 41), and its README reports 17,840 tests across 38 test executables on a Linux baseline dated 2026-08-22 (that figure moves with every batch of work, so quote it only with its date; the revision described here is sharp-runtime next @ 41b918c, 2026-09-20). It has its own version, 0.1.0-beta.1, unrelated to CNA’s. It is licensed MIT, where CNA itself is Ms-PL.

It is a sibling checkout, not a submodule: it must be present at ../sharp-runtime next to cna/ (or wherever -DCNA_SHARP_RUNTIME_ROOT=<path> points), and every CNA build requires it regardless of which renderer you select. For the current development snapshot you need sharp-runtime’s next branch: its default branch main lacks the Resources and Xml.Serialization components that CNA now requests.

CNA does not merely bundle it; CNA is built on it. Color stores bytecs channels, GameTime carries System::TimeSpan, and GraphicsResource derives from System::IDisposable. You will meet these types in almost every CNA signature you read.

ℹ

Scope, stated plainly. sharp-runtime's own README calls it "a pragmatic subset designed for use in native C++ applications", explicitly not a CLR or a full .NET runtime. Reflection, the GC, P/Invoke, serialization infrastructure, and TLS are permanent non-goals. Do not expect a C# program to port mechanically.

Two include roots

Every component keeps its public headers in modules/<component>/include/, and across all of them there are exactly two top-level header directories, which mean different things:

Include rootNamespaceWhat lives there
System/System and nested (System::IO, System::Text::Json, System::Collections::Generic, …)The BCL port itself — one header per .NET type, named exactly as in .NET.
SharpRuntime/SharpRuntimeThe project's own additions: the primitive type aliases, a property helper, and storage paths.

So the include path for .NET's System.IDisposable is "System/IDisposable.hpp", and for the type aliases it is "SharpRuntime/SharpRuntimeHelper.hpp". There is no sharp-runtime/ include prefix. A header is only on your include path when its component is part of your build, which is why the next section matters.

Build integration

sharp-runtime is a sibling checkout, not a git submodule. CNA's top-level CMakeLists.txt adds it directly and hard-errors with an explanatory message if the directory is missing. For the development snapshot, clone both repositories on their apple/m4-stabilization branches:

git clone -b apple/m4-stabilization https://github.com/libcna/cna.git
git clone -b apple/m4-stabilization https://github.com/libcna/sharp-runtime.git
# the two checkouts must be siblings; CNA resolves ../sharp-runtime

sharp-runtime is modular: each .NET namespace family is a separate CMake component with its own target, SharpRuntime::<Component> (for example SharpRuntime::Core.Base, SharpRuntime::IO, SharpRuntime::Text.Json). CNA sets the SHARP_RUNTIME_COMPONENTS cache variable to the closure it needs — Core.Base, IO, Collections.Core, Collections.ObjectModel, Runtime, Threading, Text, Globalization, ComponentModel, Storage, Security.Cryptography, Xml and Resources, plus Xml.Serialization on non-Windows targets — and then adds the sibling:

set(SHARP_RUNTIME_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory("${CNA_SHARP_RUNTIME_ROOT}" SHARP_RUNTIME)   # CNA_SHARP_RUNTIME_ROOT defaults to ../sharp-runtime

Linking CNA brings those components (and their headers) with it. Anything outside the closure is not built unless you ask for it: System/Text/Json/, networking, regular expressions and most of the rest of the tree are separate components. To use one in a game, name it before you add CNA (CNA merges its own closure into your list rather than replacing it) and link its narrow target:

set(SHARP_RUNTIME_COMPONENTS Text.Json)        # extras; CNA adds its own closure to this list
add_subdirectory(../cna CNA)
target_link_libraries(MyGame PRIVATE CNA SharpRuntime::Text.Json)

The old single static archive named SHARP_RUNTIME lives on only as a compatibility name. When you build through CNA it is an INTERFACE target that CNA re-creates over its own component closure (so it does not include extras such as Text.Json), which keeps older link lines such as CNA SHARP_RUNTIME working; standalone, sharp-runtime forwards it to SharpRuntime::All only in an All configuration. New code should link the narrow SharpRuntime::<Component> targets.

Primitive type aliases

These are the symbols you will see most often, because they appear in CNA's public signatures. They live in namespace SharpRuntime in SharpRuntime/SharpRuntimeHelper.hpp.

C# typeAliasUnderlying type
sbytesbytecsint8_t
bytebytecs (also ubytecs)uint8_t
shortshortcsint16_t
ushortushortcsuint16_t
intintcsint32_t
uintuintcsuint32_t
longlongcsint64_t
ulongulongcsuint64_t
charcharcschar16_t (UTF-16, as in C#)
IntPtrIntPtrstd::uintptr_t

A second set of aliases uses the .NET framework type names directly, which is what makes FNA/XNA C# source read almost verbatim: SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single (→ float), Double (→ double) and String (→ std::string).

⚠

There is no doublecs and no boolcs. C++ double and bool already match C# double and bool exactly, so no alias was introduced. The C#-name alias set does include SharpRuntime::Double (an alias of double, added on next), but there is no Boolean alias in the SharpRuntime namespace — System/Double.hpp and System/Boolean.hpp are separate BCL types with static members, not aliases.

Numeric limits are provided as constexpr constants alongside the aliases — INTCS_MAX/INTCS_MIN, BYTECS_MAX/BYTECS_MIN, and the equivalents for every integer alias.

Where you meet the aliases in CNA

CNA headers pull the aliases into their own namespace with using declarations, so you generally do not have to qualify them. Color.hpp is representative:

#include "SharpRuntime/SharpRuntimeHelper.hpp"

namespace Microsoft::Xna::Framework
{
    using SharpRuntime::bytecs;
    using SharpRuntime::intcs;
    using SharpRuntime::uintcs;

    class Color
    {
    public:
        CNAEXT Color(bytecs r, bytecs g, bytecs b);
        CNAEXT Color(bytecs r, bytecs g, bytecs b, bytecs alpha);

        [[nodiscard]] bytecs getRProperty() const;
        void setRProperty(bytecs value);
        // ...
    };
}

Occasionally a CNA signature qualifies the alias explicitly, and then you see the namespace. Effect's compiled-bytecode constructor is one such place:

// Microsoft/Xna/Framework/Graphics/Effect.hpp
Effect(GraphicsDevice& device, const std::vector<SharpRuntime::bytecs>& effectCode);

System::TimeSpan

System::TimeSpan is the type CNA uses for every duration. GameTime is built from two of them, and Game::TargetElapsedTime is one:

#include "System/TimeSpan.hpp"
#include "Microsoft/Xna/Framework/GameTime.hpp"

using System::TimeSpan;

// Fixed timestep: run Update() 30 times per second instead of the default 60.
setTargetElapsedTimeProperty(TimeSpan::FromSeconds(1.0 / 30.0));

void Update(GameTime& gameTime) override
{
    // getTotalSecondsProperty() returns double, not float.
    const double dt = gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty();
    angle_ += static_cast<float>(dt) * 0.8f;
}

The factory methods are FromDays, FromHours, FromMinutes, FromSeconds, FromMilliseconds, FromMicroseconds (all taking double) and FromTicks (taking longcs). Constructors take ticks, or (hours, minutes, seconds), or (days, hours, minutes, seconds, milliseconds = 0, …). The total-value accessors — getTotalSecondsProperty(), getTotalMillisecondsProperty() — return double.

System::IDisposable and CNA's Dispose pattern

System::IDisposable is a plain abstract interface with one method. There is no template parameter and no CRTP:

// System/IDisposable.hpp
namespace System {
    class IDisposable {
    public:
        virtual void Dispose() = 0;
        virtual ~IDisposable() = default;
    };
}

Its contract, as documented in the header: Dispose() must be safe to call more than once, must release everything the instance holds, and should not throw. C# has using to call it for you; C++ has destructors, so CNA leans on RAII and treats the explicit call as the early-release escape hatch.

CNA wires this into its graphics types. Every GPU-backed resource derives from GraphicsResource — VertexBuffer, IndexBuffer, and Effect directly, the texture types through the intermediate Texture base — and GraphicsResource implements the interface and adds the .NET-style protected two-argument overload:

// Microsoft/Xna/Framework/Graphics/GraphicsResource.hpp
class GraphicsResource : public System::Object, public System::IDisposable
{
public:
    void Dispose() override;
    [[nodiscard]] bool getIsDisposedProperty() const;

protected:
    // disposing == true when called from Dispose(); false from the destructor path.
    virtual void Dispose(bool disposing);

private:
    bool isDisposed_ = false;
};

When you write your own resource-holding type and want it to fit the same shape, implement the interface directly:

#include "System/IDisposable.hpp"
#include "SharpRuntime/SharpRuntimeHelper.hpp"

#include <fstream>
#include <string>

using SharpRuntime::intcs;
using SharpRuntime::Single;

class GameDataFile : public System::IDisposable
{
public:
    explicit GameDataFile(const std::string& path)
        : file_(path, std::ios::binary)
    {
    }

    // Idempotent, as the interface's own documentation requires.
    void Dispose() override
    {
        if (!disposed_) {
            file_.close();
            disposed_ = true;
        }
    }

    ~GameDataFile() override { Dispose(); }

    intcs ReadInt32()
    {
        intcs value = 0;
        file_.read(reinterpret_cast<char*>(&value), sizeof(value));
        return value;
    }

private:
    std::fstream file_;
    bool         disposed_ = false;
};

Collections

The collections live under System/Collections/Generic/ and keep their .NET names — List, Dictionary, HashSet, Queue, Stack, SortedDictionary, SortedList, SortedSet, LinkedList, PriorityQueue, OrderedDictionary, plus the interfaces (IList, ICollection, IEnumerable, IDictionary, and the read-only variants) and the comparer family. Further namespaces exist for Concurrent, Immutable, Frozen, ObjectModel, and Specialized.

List<T> wraps a std::vector<T> and exposes the .NET method names. Note that the count is a property accessor returning intcs, not a Count() method, and that a mutable element reference is deliberately not available (see the callout below):

#include "System/Collections/Generic/List.hpp"

using System::Collections::Generic::List;
using SharpRuntime::intcs;

List<std::string> names;
names.Add("Alice");
names.Add("Bob");

if (names.Contains("Alice")) {
    names.Remove("Alice");
}

const intcs count = names.getCountProperty();   // 1
const intcs where = names.IndexOf("Bob");       // 0
const std::string& first = names.getItem(0);   // read: a const reference (getItem is the explicit .NET indexer getter)
std::string copy = names[0];                   // also a read
names[0] = "Zed";                              // write through the tracked indexer (same as names.setItem(0, "Zed"))
// std::string& r = names[0];                  // does NOT compile: operator[] returns a tracking proxy, not a T&

// STL iteration works too — begin()/end() are provided.
for (const auto& n : names) { /* ... */ }

names.Clear();

Beyond the interface methods it also carries the .NET conveniences: AddRange, InsertRange, GetRange, Insert, RemoveAt, RemoveAll, Sort (with and without a comparison), Reverse, and CopyTo.

⚠

Two surfaces, two safety levels. List<T>'s header separates them. The tracked surface — operator[], getItem(), setItem() and the enumerator returned by GetEnumerator() — matches .NET: modifying the list while an enumerator is open makes the next MoveNext() throw InvalidOperationException. The STL-interop begin()/end() of a non-const list, which is what a range-for uses, yields plain std::vector<T> iterators and follows std::vector invalidation rules: a mutation during such a loop, or keeping an iterator across a reallocation, is undefined behaviour rather than an exception. Prefer the tracked surface unless you specifically need STL interop.

System::IO

System/IO/ ports the file and stream types: File, FileInfo, Directory, DirectoryInfo, FileStream, MemoryStream, BufferedStream, BinaryReader, BinaryWriter, FileSystemWatcher, the IO exception hierarchy, and the Compression, Hashing, and IsolatedStorage sub-namespaces.

The static helpers on File are the quickest way to read a save file or a config blob:

#include "System/IO/File.hpp"

using System::IO::File;

if (File::Exists(path)) {
    const std::string text  = File::ReadAllText(path);
    const auto        bytes = File::ReadAllBytes(path);  // std::vector<SharpRuntime::bytecs>
}

File::WriteAllText(path, "level=3\n");

System.Text.Json

System/Text/Json/ (the Text.Json component, which is not in CNA's default closure — select it as shown in Build integration) ports the modern .NET JSON API — JsonDocument, JsonElement, JsonSerializer, Utf8JsonWriter, the options and enum types, plus Nodes and Serialization sub-namespaces. This is genuinely useful in a CNA game, because CNA's own .cnj content format is JSON, so your tooling and your game can speak the same format.

JsonDocument::Parse returns a std::shared_ptr<JsonDocument>, and the root is reached through a property accessor:

#include "System/Text/Json/JsonDocument.hpp"
#include "System/IO/File.hpp"

using System::Text::Json::JsonDocument;
using System::Text::Json::JsonElement;

auto doc = JsonDocument::Parse(System::IO::File::ReadAllText("save.json"));
JsonElement root = doc->getRootElementProperty();

JsonElement level;
if (root.TryGetProperty("level", level)) {
    const int n = level.GetInt32();
    // ...
}

const std::string name = root.GetProperty("playerName").GetString();
ℹ

One documented deviation. .NET's JsonElement.GetString() returns string? and special-cases JSON null before its type check. This port returns std::string, so a JSON null maps to "". If the distinction matters, test getValueKindProperty() == JsonValueKind::Null first. The header says so explicitly — sharp-runtime documents its divergences rather than hiding them.

What else is in there

The System/ tree is far wider than a game needs, and browsing it is the fastest way to find out whether something you want already exists (each area below is a component; select the ones you use, as in Build integration). Beyond the areas above it covers System::Text (including RegularExpressions, Encodings, Unicode), System::Threading (with Tasks and Channels), System::Net (Http, Sockets, WebSockets, …), System::Numerics, System::Globalization, System::Diagnostics, System::Xml (with Linq and XPath), System::Security, System::Buffers, and the full exception hierarchy plus DateTime, DateTimeOffset, Guid, Random, Convert, BitConverter, Math/MathF, Span/ReadOnlySpan, Memory, Tuple/ValueTuple, and Version.

Two extras sit on the SharpRuntime/ side: SharpRuntime/Prop.hpp (a property helper), and SharpRuntime/Storage/StoragePaths.hpp, whose GetIsolatedStorageRoot() returns the std::filesystem::path that System::IO::IsolatedStorage is rooted at — the right place to put save games. (CNA’s StorageDevice::SetAppNameEXT("MyGame") scopes this root to your game.)

Using sharp-runtime without CNA

Nothing in sharp-runtime depends on CNA; the dependency runs one way only. It is a C++23 project of static libraries, so any CMake project can consume it the same way CNA does — select only the components you use and link their narrow targets (CMake resolves the dependency closure; for example Text.Json pulls in only Core.Base, Buffers, Text and Collections.Core):

set(SHARP_RUNTIME_COMPONENTS Text.Json)        # a CMake list, e.g. IO;IO.Hashing
set(SHARP_RUNTIME_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(../sharp-runtime SHARP_RUNTIME)

target_link_libraries(MyProject PRIVATE SharpRuntime::Text.Json)

It is not header-only — there is a real src/ tree per compiled component, so linking the component targets is required, not optional. It vendors GoogleTest, nlohmann/json, tinyxml2, and miniz under vendor/, each under its own permissive licence; those come into your build only with the components that use them.