Tutorial 89: Extending CNA with sharp-runtime
What you’ll learn
- What
sharp-runtimeis, and the deliberate limits of its .NET subset. - The two include roots,
System/andSharpRuntime/, and what each holds. - The primitive type aliases, and where you meet them in CNA signatures.
System::TimeSpan,System::IDisposable, collections,System::IOand 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: roughly 158,000 lines across 41 selectable modules, with over 14,000 tests. (Its README reports "14,070 passing" as of 2026-07-29 — a figure worth quoting only with its date.) 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/, and every CNA build requires it regardless of which renderer you select.
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
There are exactly two top-level directories under include/, and they mean different things:
| Include root | Namespace | What 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/ | SharpRuntime | The 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.
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:
git clone https://github.com/openeggbert/cna.git
git clone https://github.com/openeggbert/sharp-runtime.git
# the two checkouts must be siblings; CNA resolves ../sharp-runtime
add_subdirectory(../sharp-runtime SHARP_RUNTIME)
The resulting CMake target is a static library named SHARP_RUNTIME, and it publicly exports its include/ directory. CNA's own example targets link both:
target_link_libraries(MyGame PRIVATE CNA SHARP_RUNTIME)
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# type | Alias | Underlying type |
|---|---|---|
sbyte | sbytecs | int8_t |
byte | bytecs (also ubytecs) | uint8_t |
short | shortcs | int16_t |
ushort | ushortcs | uint16_t |
int | intcs | int32_t |
uint | uintcs | uint32_t |
long | longcs | int64_t |
ulong | ulongcs | uint64_t |
char | charcs | char16_t (UTF-16, as in C#) |
IntPtr | IntPtr | std::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), 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. Nor is there a Double or 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:
#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
std::string& first = names[0];
// 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.
Iterator invalidation is C++'s, not C#'s. List<T>'s own header notes that iterating with begin()/end() follows plain std::vector<T> invalidation rules — a mutation during iteration is undefined behaviour here, rather than the InvalidOperationException C# would throw.
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/ 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. 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.
Using sharp-runtime without CNA
Nothing in sharp-runtime depends on CNA; the dependency runs one way only. It is a C++23 static library, so any CMake project can consume it the same way CNA does:
set(SHARP_RUNTIME_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(../sharp-runtime SHARP_RUNTIME)
target_link_libraries(MyProject PRIVATE SHARP_RUNTIME)
It is not header-only — there is a real src/ tree, so linking the SHARP_RUNTIME target is required, not optional. It vendors GoogleTest, nlohmann/json, tinyxml2, and miniz under vendor/, each under its own permissive licence.