Chapter 53 Why sharp-runtime Exists
This book’s ecosystem chapter introduced sharp-runtime as the foundation layer every XNA-facing CNA type quietly depends on. This chapter treats it as a project in its own right — one considerably larger, and considerably more broadly scoped, than its role inside CNA alone might suggest.
53.1 Scale
At the audited revision, sharp-runtime contains 1,032 public headers plus five private implementation headers, 217 implementation files, and 78,934 lines of production C/C++. Its tests are larger still: 102,342 lines, roughly 30% more than production. The registered GoogleTest surface contains 16,310 tests across 37 executables; the repository’s continuation record reports 16,303 passing, one skipped, and six failing, rather than concealing those failures behind an obsolete all-green count. This is a considerably larger surface than “just enough .NET to make XNA’s API vocabulary work in C++” might suggest — coverage genuinely extends to System.Text.Json, System.Xml.Linq / XPath, System.Buffers.Text / Binary, System.Threading.Channels, and System.Net.WebSockets / Http / Security, well beyond a minimal XNA-support subset.
53.2 Namespace structure
The public headers live below two include roots. System/ mirrors real .NET’s namespace tree directly: Collections (with Concurrent, Frozen, Generic, Immutable, ObjectModel, Specialized sub-namespaces), IO (with Compression, Hashing, IsolatedStorage), Net (with Http, Sockets, WebSockets, Security), Text (with Json, RegularExpressions, Encodings), Threading (with Channels, Tasks), Xml, Security, Globalization, Runtime, and roughly 185 flat top-level types (Object, String, Exception, EventHandler, IDisposable, TimeSpan, DateTimeOffset, Delegate, Guid, Random, Uri, Version, Nullable, Span, Memory, and more). A separate SharpRuntime/ namespace holds project-internal helpers that are not part of the .NET mirror at all — most importantly SharpRuntimeHelper.hpp, home to the type-alias table Chapter 3 already introduced (bytecs, intcs, Single, and the rest).
There is no umbrella header. The on-disk include spelling follows the fully-qualified .NET name mechanically: System.IO.FileStream becomes System/IO/FileStream.hpp. CNA consequently consumes far more than the two headers whose spelling begins with SharpRuntime/: its source directly includes 54 distinct headers from this repository, 52 below System/ and two below SharpRuntime/. Those two are SharpRuntimeHelper.hpp and Prop.hpp; the latter exports property-declaration macros used downstream even though the runtime’s own modules do not use it. Chapter 54 maps these include paths to their owning link components.
53.3 Object and IDisposable: deliberately not two roots
System::Object is not a universal root for this C++ type system. It is an abstract, vestigial compatibility type with a pure-virtual GetTypeName(), and only DateTime and DateTimeOffset derive from it in the entire runtime. Stream does not; exceptions derive from std::exception. Its GetHashCode(), Equals, ToString(), and RTTI-backed GetType() are useful to the few types that opt in, while the GetTypeNameHPP() / GetTypeNameCPP() macros reduce their override boilerplate. Reading those helpers as evidence that every ported class inherits Object would import a .NET invariant the implementation intentionally does not have.
System::IDisposable is a single pure interface method, virtual void Dispose() = 0; its own documentation is explicit that C++ has no equivalent of C#’s using statement, so callers either use RAII or call Dispose() explicitly, and every implementation is expected to be idempotent. It is a capability interface, not a second hierarchy root.
53.3.1 String and Exception expose the same adaptation strategy
System::String is a non-instantiable collection of static source-porting helpers; both its constructor and destructor are deleted. Runtime strings remain std::string, also exported as SharpRuntime::String. Likewise, System::Exception derives from std::exception, carries an exception_ptr for its inner exception and a default HRESULT, and returns an empty stack trace because no managed stack is available. These are deliberate C++ representations of useful .NET behavior, not attempts to reproduce the CLR’s object layout.
53.4 EventHandler and MulticastAction: solving a problem C# doesn’t have
System::EventHandler<TEventArgs> deliberately diverges from real .NET’s own design, and its own header comment states why directly: in C#, EventHandler<T> is only a delegate type — the actual multicast subscriber list and invocation mechanism are supplied separately, by the compiler-generated code behind the event keyword. C++ has no event keyword, so sharp-runtime’s own EventHandler<T> has to be both the type and the subscriber-list infrastructure at once: operator+=, a token-based Add / Remove pair, Clear(), and Raise(sender, e). Raise specifically invokes over a snapshot of the current handler list, not the live list — a direct, deliberate guard against a handler that removes itself (or another handler) from within its own invocation, which previously surfaced as a real std::bad_function_call crash before this snapshot discipline was adopted. A further mechanism, SetReplayHook(), exists specifically to model a real XNA quirk this book has already touched on earlier — NetworkSession.GamerJoined (Chapter 51) is expected to replay already-happened state to a newly-added subscriber, not only notify about future events, and this hook is the general mechanism that makes that pattern implementable once, rather than reinvented per event.
System::MulticastAction<Args...> is a second, purpose-built multicast type built for the same underlying reason: since std::function/lambda values have no C#-style target-plus-method equality, ordinary value comparison cannot implement C#’s -=-style single-subscriber removal, so this type uses the same token-based-removal-plus-snapshot-invocation discipline as EventHandler<T> to solve the identical problem for plain multicast callback fields that are not shaped like a C#-style public event. The motivating real-world case, named directly in this type’s own header comment, is re-parenting a scene-graph hierarchy: a node that resubscribes to a different set of ancestor callbacks needs to drop its specific old subscription without clearing every other subscriber sharing the same field, which plain value comparison over std::function objects cannot distinguish.
53.5 Value types used by later chapters
System::TimeSpan, the type underlying GameTime and SensorBase::TimeBetweenUpdates throughout this book, stores its value internally as 100-nanosecond ticks and carries one small, diagnostic-only surprise: static copy and move counters exposed for test instrumentation. Both counters are now std::atomic<int>; loads, resets and increments use relaxed atomics because only each final count matters, not an ordering relationship with the copied value. A downstream sanitizer suppression that still describes their increments as a known TimeSpan race therefore documents an older implementation, not the pinned runtime.
Collections::Generic::List<T> and Dictionary<TKey,TValue> both implement .NET’s fail-fast collection-modified-during-enumeration behavior via an internal version counter checked on every enumeration step — with one small, explicitly documented deviation on Dictionary: its Remove() bumps the version counter, which real .NET’s own Dictionary.Remove() does not do, because std::unordered_map’s own iterator-invalidation guarantees on erase differ from the array-backed entry table real .NET’s dictionary uses internally — a deliberate, reasoned adaptation to the underlying C++ standard library type, not an oversight. This is not just a two-type pattern: every mutable generic collection this library ships — HashSet, LinkedList, Queue, Stack, SortedDictionary, SortedList, SortedSet, and OrderedDictionary alongside the two above — carries the identical version-counter invariant, made project-wide as a deliberate late addition rather than left as a two-type special case. OrderedDictionary specifically needed a follow-up fix of its own even after that rollout: its EnsureCapacity method resized the backing storage without bumping the version counter, meaning an enumerator already in flight across a capacity-triggered resize would not detect the mutation the way every other structural change on the same type correctly does — closed as its own, narrower fix once found, on the same underlying invariant.
53.6 Concurrency primitives
Threading::Tasks::Task implements a real, if scoped, async-task model: Wait() rethrows a faulted task’s exception (matching .NET’s own Wait() / ThrowIfExceptional contract), and ContinueWith uses weak-pointer-based cycle avoidance specifically to avoid leaking a continuation chain rather than spawning an extra OS thread per continuation. A detail worth stating plainly, since it governs how a caller should reason about ordering: this port has no thread pool or scheduler at all, so a continuation always runs synchronously and inline, either on whichever thread completes the antecedent task or, if the antecedent is already complete by the time ContinueWith is called, immediately on the calling thread — effectively as if TaskContinuationOptions::ExecuteSynchronously were always set, regardless of whether a caller actually passes it. Most of the accepted TaskContinuationOptions filter bits (NotOnFaulted / NotOnCanceled / NotOnRanToCompletion and their OnlyOnX compositions) are genuinely honored; the rest (PreferFairness / LongRunning / AttachedToParent / DenyChildAttach / HideScheduler / LazyCancellation) are accepted only for API-surface parity, since there is no scheduler or parent-task tracking for them to affect in the first place.
53.6.1 WhenAll cancellation and the external-future bridge
Task::WhenAll and Task::WhenAny round out the async surface, and WhenAny specifically was rebuilt to match real .NET’s own zero-extra-thread design (TaskFactory.CommonCWAnyLogic’s completion-registration strategy) rather than the naive one-watcher-thread-per-input-task approach it started as. The current implementation registers an inline callback on each input’s shared state; the first callback wins an atomic compare-exchange and later callbacks become cheap no-ops. No losing watcher is joined or detached because the current path creates no watcher threads at all. WhenAll waits on every input task to completion (never short-circuiting on the first fault, matching real .NET) and, if one or more faulted, rethrows the first one encountered in input order when the returned task is waited on — a deliberate simplification versus real .NET’s AggregateException wrapping, consistent with the same choice Task::Wait already makes for a single faulted task. If no input faults but one is canceled, the action implementing WhenAll throws TaskCanceledException; because that returned task has no associated cancellation token, its constructor records the escape as Faulted, not Canceled. Waiting still throws the expected exception type, but the status flags remain a documented deviation from .NET.
WhenAll must distinguish that genuine canceled-input state from a task that merely faulted with a directly thrown TaskCanceledException; it now checks the input task’s status instead of sniffing the exception type. The same category of defect previously existed in the external-future bridge used by TaskCompletionSource: both TrySetCanceled() and a caller’s SetException(TaskCanceledException) settle a promise with that exception type. A separate producer-set atomic flag now identifies only the genuine cancellation path, preserving the caller’s exception and Faulted state otherwise. WhenAny needs neither rule: its outer TaskT<Task> always completes successfully with the first terminal input, whose own fault/cancellation status the caller inspects.
Threading::Thread is a thin wrapper over std::thread that deliberately does not start at construction — a second call to Start() throws ThreadStateException, matching real .NET’s own one-shot-start contract exactly, rather than silently allowing a thread object to be restarted the way a naive wrapper might.
53.7 Permanent scope boundaries: what will never be ported, and why
sharp-runtime’s own CLAUDE.md states its scope discipline directly, and it is worth quoting rather than paraphrasing, since the wording distinguishes a real category this book’s own methodology depends on: “Known permanent deviations (not bugs, not TODO).” Five areas are named explicitly, each with a stated reason rather than left as an unexplained gap — the delegate-tier deviation is the one already covered in full in Chapter 57 (the three-tier Action / Delegate/ MulticastAction split); the remaining four are covered here:
- Reflection
-
(System::Type, System::Activator, Enum.GetNames / GetValues) — “completely out of scope. Stubs are the correct end state,” not a gap awaiting engineering effort.
- GC
-
(System::GC) — mutating operations are callable no-ops and queries return documented sentinel values (for example zero, false, NotApplicable, or the constant three-generation maximum); memory is managed by RAII / std::shared_ptr throughout, so there is no garbage collector for these calls to meaningfully forward to.
- Serialization
-
([Serializable], SerializationInfo) — ignored outright; not needed for game code.
- P/Invoke and interop
-
— out of scope entirely.
- Cryptography
-
(symmetric/asymmetric ciphers, X.509 certificates, TLS — Aes* / RSA* / EC* / ChaCha20Poly1305 / CryptoStream, X509Certificates, SslStream) — out of scope by an explicit, dated decision (2026-07-07): correctly implementing this needs either a large new external dependency (OpenSSL/mbedTLS) or a hand-rolled, security-critical implementation, and neither is worth it for game code. This deviation is deliberately narrow, not a blanket “no crypto” rule: digest, authentication, and derivation APIs (MD5 / SHA* / HMAC / PBKDF2) remain in scope and are already ported. HMAC keys and PBKDF2 passwords are sensitive material even though these APIs do not provide reversible confidentiality, X.509 validation, or a TLS channel.
53.7.1 Reflection’s stub and its deliberate contradiction
System::Type’s own header comment is worth reading in full for what it says about how a permanent stub should behave, not just that it exists: constructed via Type::From<T>(), backed by C++ RTTI’s std::type_info rather than any real reflection metadata, its boolean predicates (IsClass, IsValueType, IsAbstract, IsSealed, IsInterface) each return a fixed value regardless of what the actual type argument is — because C++ RTTI cannot determine any of them — and the comment states plainly that these fixed values are not mutually consistent: Type::From<int>() reports both IsClass()==true and IsValueType()==false simultaneously, a combination that would be self-contradictory for a real .NET type (int is IsValueType==true, IsClass==false). This is not an oversight left for a future fix — the header says explicitly that these predicates exist only so ported code calling type.IsClass still compiles, not to answer the question correctly, and that calling code must not branch on them to distinguish value types from class types. System::Activator narrows the same permanent gap to exactly the part that survives the loss of reflection: its real, callable CreateInstance<T>() template constructs any default-constructible T at compile time, while the reflection-based overloads real .NET exposes — CreateInstance(Type), CreateInstanceFrom — are absent entirely, not stubbed, because sharp-runtime has no System.Reflection for them to resolve a Type argument against in the first place.
53.8 The build is part of the public architecture
Since the 2026-08-10 modularization merge, sharp-runtime is not one static archive. Its 41 module directories register 44 link components: 30 static libraries, 13 interface components, and the Xml.XPath alias of Xml. Compatibility umbrellas named Core and Collections coexist with the narrower components. The distinction matters to CNA because an include path names a source-level dependency while a SharpRuntime::<Component> target declares its link closure. The complete registry, component selection rules, and CNA’s old-checkout adapter are covered in Chapter 54.
One platform-specific edge remains useful context here. On Android, the storage-path implementation calls SDL3 path APIs, so a parent such as CNA must have created an SDL3::SDL3 target before adding the runtime. This conditional edge does not turn SDL into a universal runtime dependency.
53.9 Local and tracked CI gates have different scope
The local gate configures and builds under strict shell failure handling and rejects compiler warnings and errors. The modular test topology is finer-grained than the old single-binary design: 36 component executables plus one integration executable feed GoogleTest discovery. Each component test links its own component and declared test-only dependencies, so an undeclared cross-module use can fail at link time instead of remaining hidden inside a monolith. Python meta-tests and deliberately failing consumer fixtures add build-boundary checks that ordinary positive unit tests cannot express.
The pinned repository also tracks .github/workflows/components.yml. On every push and pull request it runs nine selective Ubuntu configurations, a full compatibility build, and a separate Doxygen-warning job, with the build-job ceiling fixed at two. That proves a hosted trigger and named Linux jobs exist; it does not prove Windows, macOS, Emscripten, or every historical commit ran successfully. Older continuation prose saying no workflow existed was superseded by the workflow now present in the pinned tree.
53.9.1 Recorded failures and platform claims need scope
The current continuation note records six failing and one skipped test; an older baseline that claims every test passes is therefore historical, not authoritative. The same discipline applies to platform reports: a past MinGW or Emscripten library build does not prove that all 37 current executables cross-compile, much less that they execute on their target. Native Linux results establish neither a cross compiler’s header and linker path nor runtime behavior on a different operating system. Keeping those scopes separate is part of the evidence model, not mere cautionary wording.