Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

Chapter 57 Parity and Permanent Deviations

Every project this book has covered draws a line between what it will faithfully reproduce and what it will not. Sharp-runtime states its target as maximum practical parity: public API, semantics, defaults, error messages, and algorithms should match .NET as closely as C++ allows. That target is stronger than name compatibility and narrower than reimplementing the CLR. Its permanent deviations are part of the contract, not embarrassing footnotes.

57.1 Three outcomes, not one generic stub

An implementable operation should behave like .NET. An operation whose mechanism cannot be represented should throw NotImplementedException with an explanation rather than return a plausible but wrong value. A meaningful operation that the current host cannot provide should instead throw PlatformNotSupportedException. These outcomes answer different questions:

Parity

the operation is implemented and its observable behavior is the verification target;

Permanent deviation

the CLR-dependent capability is outside the runtime’s design;

Platform limitation

the abstraction is valid, but this platform lacks its mechanism.

A silent success value is not a fourth acceptable category. This taxonomy lets callers and tests distinguish a design boundary from an environment boundary.

57.2 Reflection: correctly a stub, not a gap

System::Type, Activator, and enum reflection (Enum.GetNames / GetValues) are, in the project’s own words, “completely out of scope. Stubs are the correct end state.” This is a stronger claim than “not yet implemented” — it states that a stub is the intended final behavior, not a placeholder awaiting more engineering. Reflection depends on runtime type metadata a compiled C++ binary simply does not carry the way a .NET assembly does; reproducing it would mean building a real, if partial, runtime type system from scratch, disproportionate to what CNA’s own XNA-facing code actually needs.

57.3 Garbage collection: inert compatibility answers, by design

System::GC has no collector behind it. Mutating methods such as Collect and SuppressFinalize are no-ops, while queries return fixed documented answers: allocation statistics are zero, no-GC-region requests return false, notification waits report NotApplicable, and MaxGeneration remains the compatibility constant 2. Object lifetime is managed by RAII and shared_ptr; callers may compile defensive GC calls but must not infer live heap telemetry from the surface.

57.4 Delegates: a three-tier model, corrected once in the project’s own history

This is worth covering in detail because the project’s own documentation records a real self-correction: an earlier description of the delegate model was found inaccurate during an audit and rewritten. The corrected, current model has three distinct tiers, each solving a different shape of problem. Most delegate-shaped C# types — Action, Func, most *Callback and *EventHandler aliases — are bare using aliases over std::function: single-target, no multicast, no BeginInvoke / EndInvoke, sufficient for the overwhelming majority of call sites that only ever needed one callback anyway. System::Delegate is a real multicast base (Combine, Remove, GetInvocationList) for the smaller set of call sites that genuinely need C#’s multicast-delegate combining semantics. System::MulticastAction<Args...> (Chapter 53) is a third, purpose-built type specifically for multicast event fields, using token-based removal since C++ callables have no target-plus-method equality to compare by the way C# delegates do. The specialized EventHandler<TEventArgs> collection applies the same event-field idea to the sender/argument shape and adds an optional replay hook. Of these representations, only Delegate exposes DynamicInvoke; it always throws NotImplementedException. The alias and event-field types do not pretend to expose a late-bound argument-array call at all.

1 // Worked example: all three tiers side by side, each solving the shape of
2 // problem it exists for.
3
4 // Tier 1 -- Action/Func alias: single-target, the overwhelming common case.
5 System::ActionT<int> onScoreChanged = [](int newScore) { UpdateHudScore(newScore); };
6 onScoreChanged(100);
7
8 // Tier 2 -- System::Delegate: real multicast combining semantics, when a
9 // caller genuinely needs to build up and later shrink an invocation list.
10 auto d1 = std::make_shared<System::Delegate>(System::Delegate::ErasedInvoke([] { PlayFanfare(); }));
11 auto d2 = std::make_shared<System::Delegate>(System::Delegate::ErasedInvoke([] { UnlockNextLevel(); }));
12 std::shared_ptr<System::Delegate> onLevelComplete = System::Delegate::Combine(d1, d2);
13 onLevelComplete->Invoke(); // runs both
14 onLevelComplete = System::Delegate::Remove(onLevelComplete, d1); // now only d2 remains
15
16 // Tier 3 -- MulticastAction: the right choice for an event FIELD, since
17 // std::function values have no target-plus-method equality to remove by.
18 System::MulticastAction<> onGameOver;
19 auto token = onGameOver.Add([] { ShowGameOverScreen(); });
20 onGameOver.Remove(token);

57.5 Serialization, P/Invoke, and cryptography: three different reasons for the same conclusion

Serialization is “ignored, not needed for game code” — a scope judgment rather than a technical impossibility. P/Invoke and general native interop are out of scope for the same underlying reason Chapter 3’s type-alias table exists in the first place: this project already is native code, so a marshaling layer for calling into native code from managed code has no problem left to solve here.

Cryptography and TLS are the one deviation with the most fully spelled-out reasoning of all, and it deserves quoting closely: symmetric and asymmetric cryptography, X.509 certificates, and SslStream are out of scope by an explicit, dated project-owner decision, on the grounds that “implementing this correctly needs either a large new external dependency (OpenSSL/mbedTLS) or a hand-rolled, security-critical implementation, neither of which is worth it for game code.” This is a materially different kind of reasoning than the other deviations in this chapter — not “this feature has no meaning here” (reflection, GC) and not “this is out of scope for capacity reasons” (serialization), but an explicit refusal to carry the security liability of a hand-rolled cryptographic implementation, or the dependency weight of a real one, for a class of application that does not need transport security built into its own runtime layer. Digest and derivation APIs — MD5, SHA, HMAC, and PBKDF2 — remain in scope. That does not make them “non-cryptographic,” nor does it imply that HMAC or PBKDF2 has no key or password material. The narrower project distinction is that these APIs do not implement reversible confidentiality, certificate validation, or a TLS channel. Callers still own algorithm choice, parameter strength, and security review.

57.6 IDisposable and the exception hierarchy: parity by pattern, not by mechanism

Two further deviations are less often stated outright as deviations, because the project does not treat them as ones — they are cases where matching .NET’s observable behavior required deliberately not matching its mechanism. IDisposable’s own header comment states the tradeoff directly: “In C#, the using statement calls Dispose() automatically at the end of the block. In C++, use RAII or call Dispose() explicitly.” There is no C++ language construct that runs arbitrary code at scope exit the way a using block does short of writing a dedicated RAII wrapper per call site, so this project’s actual parity target is narrower and more precise than “reproduce the using statement”: reproduce Dispose()’s own contract — idempotent, generally non-throwing, and safe to call more than once — and let the caller choose their own C++-native mechanism (an explicit call, a destructor, or a scope guard) for invoking it at the right time.

System::Threading::ReaderWriterLockSlim is a concrete, real implementation of this contract worth reading closely, because its own header comment documents a genuine self-correction of exactly the kind this chapter’s delegate discussion above already showed this project performing on itself. An earlier version of this class had three real, independently-found bugs, each one a case where a C++ implementation that merely looked equivalent to the real .NET semantics was not actually equivalent under closer verification: it discarded the millisecondsTimeout parameter on every TryEnter* overload, always making a single non-blocking attempt regardless of what the caller passed; it ignored the constructor’s LockRecursionPolicy entirely, so same-thread recursive acquisition always deadlocked instead of throwing LockRecursionException under the real .NET-default NoRecursion policy; and it tracked reader-lock ownership by set membership rather than by count, so a legitimately nested EnterReadLock() / EnterReadLock() / ExitReadLock() sequence’s second ExitReadLock() call threw SynchronizationLockException instead of correctly decrementing — which additionally meant the internal reader tally could never reach zero from a genuinely nested acquisition, permanently starving any waiting writer. All three are fixed in the current header, verified directly against real .NET’s own TryEnterReadLockCore / TryEnterWriteLockCore / TryEnterUpgradeableReadLockCore logic per the header’s own verification comment.

1 // Worked example: IDisposable’s contract, applied explicitly (no C++ "using"
2 // block exists), with the real ObjectDisposedException guard every public
3 // entry point in ReaderWriterLockSlim shares (throwIfDisposed(), called
4 // first inside TryEnterReadLock/TryEnterWriteLock/TryEnterUpgradeableReadLock).
5 System::Threading::ReaderWriterLockSlim rwLock;
6
7 rwLock.EnterReadLock();
8 // ... read shared state ...
9 rwLock.ExitReadLock();
10
11 rwLock.EnterWriteLock();
12 // ... mutate shared state ...
13 rwLock.ExitWriteLock();
14
15 rwLock.Dispose(); // explicit -- no using-block equivalent exists
16 rwLock.Dispose(); // safe to repeat: Dispose() only ever sets a flag
17 try {
18 rwLock.EnterReadLock(); // throwIfDisposed() now fires
19 } catch (const System::ObjectDisposedException&) {
20 // "ReaderWriterLockSlim" -- the disposed object’s own class name,
21 // matching real .NET’s ObjectDisposedException(objectName) constructor.
22 }

The exception hierarchy this last block leans on is itself worth a concrete scale rather than a vague gesture at “lots of exception types”: a project-wide search finds roughly 111 exception classes across the include/System tree, the overwhelming majority deriving not directly from System::Exception but through the intermediate System::SystemException base — matching real .NET’s own two-level shape, where framework-thrown exceptions (ArgumentException, InvalidOperationException, ObjectDisposedException, and the rest this book’s other chapters have already used by name) sit under SystemException rather than hanging directly off the root. The root class’s own header comment makes explicit exactly which real .NET Exception members this project deliberately does not provide, and why each one is a concrete instance of a deviation this chapter has already stated abstractly: GetBaseException() would require cloning the thrown object through its base type, which needs a virtual-clone mechanism this port does not have; ToString() would need a GetType()-equivalent class name, out of scope under this chapter’s opening reflection deviation; and TargetSite and GetObjectData are out of scope for the same reflection and serialization deviations covered earlier in this chapter. Nothing here is a new decision — it is the same two decisions, now traceable to the one root class every exception in the ecosystem inherits from.

57.7 Naming is a compatibility surface

Sharp-runtime uses PascalCase .NET names, but C++ cannot expose C# properties directly. The binding convention is getXxxProperty() and setXxxProperty(); indexers are the systematic exception and use getItem() / setItem(). Public headers contain thousands of these accessors, so a cosmetic refactor would be an ecosystem break rather than a local cleanup.

The project’s own rules name CNA as the reason broad header renaming is frozen. That is strong evidence of co-evolution: the runtime is a separate repository, yet its source conventions are constrained by CNA’s compiled public surface. Porting rules similarly require the intcs/bytecs family and direct nested namespace syntax, while LINQ-shaped work should use std::ranges rather than growing a second query runtime.

When a naming correction is nevertheless intentional, the project does not preserve a stale alias indefinitely. The getCurrent() to getCurrentProperty() migration changed 61 call sites across 23 files in one commit and required downstream repair. The lesson is not that names may churn casually; it is that a deliberate convention repair should be atomic, auditable, and free of permanent compatibility debris.

57.8 Two further, narrower accepted boundaries

Platform behavior is split more finely than a single “POSIX-only” label. Sockets and IO::RandomAccess have Windows and POSIX implementations and throw on Emscripten. AppDomain’s base directory works on Windows, macOS and Linux/POSIX, with a relative virtual-filesystem fallback on Emscripten; TimeZoneInfo likewise has Windows and POSIX paths but only UTC/local fallback there. Diagnostics::Process and POSIX-signal registration are POSIX implementations that throw on Windows/Emscripten, while NetworkInterface enumeration and FileSystemWatcher are narrower Linux-only operations. Unsupported paths compile and fail explicitly rather than disappearing from the headers.

Separately, Decimal, Int128, and UInt128 require a compiler-provided 16-byte __int128. The CMake probe admits x86-64 GCC/Clang, including x86-64 MinGW GCC, but excludes MSVC and i686 MinGW GCC. This is a compiler-capability boundary, not simply a Windows boundary, and reflects the deliberate, dated decision not to hand-roll 128-bit arithmetic for toolchains that lack the native type.

57.9 A documented deviation still needs verification

Calling a gap permanent does not exempt its boundary from tests. GC’s inert methods and sentinel queries should remain callable and stable; Type’s identity operations must work even though its classification predicates are placeholders; Delegate::DynamicInvoke must fail explicitly; and unsupported platform operations must throw the promised exception rather than silently degrade. Chapter 58 shows how component tests, negative consumers, sanitizer runs, and the repository’s large audit ledger test both implemented behavior and the honesty of these boundaries.

Type at least three characters. Results are ranked by how often and where the words occur.