Skip to content

Latest commit

 

History

History
230 lines (204 loc) · 50.9 KB

File metadata and controls

230 lines (204 loc) · 50.9 KB

CNA Port Checklist

Use this checklist for every .cs file ported from FNA to CNA.


Per-file checklist

Headers / boilerplate

  • // SPDX-License-Identifier: MS-PL present in .hpp
  • // SPDX-License-Identifier: MS-PL present in .cpp
  • #include "CNA/CNAHelper.hpp" present in .hpp if CNAEXT is used anywhere

Doxygen documentation

  • Every public method, constructor, operator, property getter/setter, and constant in the .hpp has a /** @brief … */ Doxygen block comment
  • Methods with parameters have @param for each parameter
  • Non-void methods have @return
  • No public member is left undocumented
  • No bare /// comments on public API declarations (only /** */ blocks allowed)

API surface (compare against the XNA reference assemblies, NOT only FNA)

FNA is authoritative for BEHAVIOR. The XNA 4.0 reference assemblies are authoritative for API SURFACE. FNA is a reimplementation targeting what games actually call on desktop, and it genuinely omits public members that exist in XNA 4.0 — members tied to Xbox 360/Zune/Windows Phone concepts, or that FNA's own renderers cannot populate, are dropped rather than stubbed. Auditing "CNA vs FNA" can therefore come back clean while real API surface is missing.

Confirmed case (2026-07-18, plan_media.md MEDIA-174/180): XNA's Song declares Album, Artist, Genre and ToString(). FNA's src/Media/Song.cs has none of them, so CNA had none either — and eight consecutive adversarial review rounds all missed it, because every one of them also audited against FNA. It was only found by diffing against the original Microsoft reference assemblies.

Reference location: /rv/data/library/github.com/borgesdan/xn65/references/Windows/Microsoft.Xna.Framework.xml (plus the sibling .Video.xml, .Graphics.xml, .Net.xml, … for their namespaces).

Mechanical check — do this per type, it takes seconds:

grep -E 'name="[PMF]:Microsoft\.Xna\.Framework\.<Ns>\.<Type>\.' Microsoft.Xna.Framework.xml

Map P:getXProperty/setXProperty, M: → same method name, F: → enum value/constant. Note the established C++ idiom equivalents so they are not misreported as gaps: op_Equality/op_Inequalityoperator==/operator!=; the C# indexer Itemoperator[]; GetEnumeratorbegin()/end(); System#Collections#IEnumerable#GetEnumerator → no C++ equivalent, N/A.

If FNA and the reference disagree about a member existing, implement per the reference and record the FNA divergence below. "FNA doesn't have it either" is not a justification — that is exactly the reasoning that let the Song gap survive eight reviews.

  • Type's full member list extracted from the XNA reference XML and diffed against CNA
  • All public fields / constants present
  • All public properties mapped to getXProperty() / setXProperty()
  • All public methods present with correct signatures
  • All public static methods present
  • All events present as System::EventHandler<TArgs> fields
  • All ref/out overloads present as value-ref pairs
  • operator== / operator!= present if the XNA reference defines op_Equality/op_Inequality
  • Behavior of each member verified against FNA (FNA still wins on semantics)

Inheritance

  • All interfaces from FNA implemented (e.g. IEquatable<T>System::IEquatable<T>, IComparable<T>System::IComparable<T>, IDisposableSystem::IDisposable)
  • Methods that implement interfaces have override keyword

CNAEXT markers

  • Every method / field / type alias not in the XNA 4.0 API surface is marked CNAEXT
  • C++ iterator support (begin, end, size_type, …) marked CNAEXT
  • GetTypeName() marked CNAEXT (applies to classes that inherit System::Object)

GetTypeName()

  • Concrete classes that inherit System::Object override GetTypeName() with CNAEXT
  • Return value is the fully-qualified .NET name, e.g. "Microsoft.Xna.Framework.Foo"

Logic verification (method by method vs FNA)

  • Each method body compared line-by-line with the FNA equivalent
  • Every intentional deviation from FNA logic has a // comment explaining why
  • Null/range guard differences between C# and C++ documented where relevant

Tests

  • Test file exists at tests/Microsoft/Xna/Framework/<ClassName>Tests.cpp
  • Every public method has at least one test
  • Edge cases covered: boundary values, empty collections, null/nullptr inputs where applicable
  • ref/out overloads tested separately
  • Event firing verified with a lambda subscriber
  • GetHashCode(): equal objects → equal hash; different objects → (typically) different hash
  • ToString(): format spot-checked against FNA output

Classes that cannot be unit-tested

If the class depends on Game / SDL / graphics renderer, document it and skip tests:

  • Add comment in test suite directory or // No tests: requires SDL/Game at the top of a stub file

Adding a new renderer family

Beyond the identity registries (GraphicsRendererType.hpp, cmake/RendererSelection.cmake, scripts/check_renderer_identities.py), a new renderer family must provide the runtime-dispatch surface (plan_runtimerenderer.md):

  • modules/renderers/<family>/src/<X>RendererDescriptor.cpp defining CNA::Internal::Renderers::<Family>::GetDescriptor(). Enforced by scripts/check_runtime_renderer_discipline.py — exactly one per family.
  • CreateGraphicsRenderer defined in the family's own namespace, not in CNA::Internal::Renderers. A shared factory symbol is what made multi-renderer builds impossible. Enforced by the discipline gate.
  • The descriptor answers the pre-window questions honestly: needsWindow, needsVideoSubsystem, windowKind, wantsHighDpi, glFramebuffer, and which platform services the family receives (needsSurfacePresenter, needsGlContext, needsVulkanSurface). These are data, not hooks — a descriptor never names a windowing library.
  • windowKind set to the kind the renderer's window really is — fallback uses it to decide whether a window can be reused or must be recreated. A family whose native API is chosen at runtime (BGFX, LLGL, FNA3D, DILIGENT, IGL) must report the kind it will actually ask for, from the same resolution the renderer itself uses; BGFX once hardcoded Vulkan while asking for a GL window, which only became visible when the cross-kind branch went live.
  • isAvailable() is a real probe where one is cheap and side-effect-free; AlwaysAvailable otherwise. Returning true is not a promise construction will succeed.
  • The family's identity registered in cmake/RendererRegistry.cmake's namespace map. Enforced by the discipline gate, which checks the whole chain — identity → namespace → descriptor accessor — for every public identity. PIXIJS shipped without this entry and could therefore never have configured; the gate exists because nothing caught that.
  • The identity's CNA_RENDERER_<X> macro announced in cmake/RendererSelection.cmake with list(APPEND _cna_identity_defines ...), never add_compile_definitions(). The latter is directory-scoped and would define a non-default identity's macro project-wide. Enforced by the discipline gate.
  • Any renderer-specific behaviour reaches the XNA layer through an IGraphicsRenderer virtual or a GraphicsRendererDescriptor::adapterQueries hook — never an #ifdef in modules/graphics/src. Enforced by the discipline gate.
  • If it cannot coexist with another renderer, a rule in cmake/RendererCombinations.cmake and a row in docs/runtime-renderer-selection.md. Enforced by scripts/check_renderer_combinations.py.

Known acceptable C++ deviations from FNA/XNA

Deviation Reason
GetHashCode() returns std::size_t instead of int C++ hash size is platform-native
ref/out params become value-reference pairs No C# ref/out in C++
IEnumerable<T> replaced by begin()/end() (CNAEXT) C++ iterator idiom
Type-based service lookup uses typeid / templates No C# reflection
Type-assignability check in AddService omitted No runtime reflection in C++
Equals(object obj) override omitted No object base in C++ structs/value types
DeviceCreated/DeviceDisposing event hookup simplified Service always available in CNA
IsAssignableFrom check in GameServiceContainer omitted No runtime reflection
C# internal set mapped to private + friend class <OneSpecificClass> (e.g. Microsoft::Devices::Sensors::AccelerometerReading's setters, friended to Accelerometer only) C++ friend is per-named-class, not assembly-scoped like C#'s internal — narrower than the real API but the closest available mechanism; acceptable since each reading type has exactly one producing sensor class
C# internal const/internal static readonly field exposed as CNAEXT public static constexpr (e.g. GamePad::LeftDeadZone/RightDeadZone/TriggerThreshold, TouchPanel::MAX_TOUCHES/NO_FINGER) C++ has no assembly-scoped visibility; the constant is genuinely needed from another translation unit (e.g. GamePadThumbSticks.cpp/GamePadTriggers.cpp reading GamePad's constants), so it is exposed publicly with a CNAEXT tag and a @note citing the FNA internal declaration, rather than duplicated or left unreachable. Confirmed during Input Phase 1 (plan_input.md P1-003/P1-025)
GetHashCode() stays int-returning (matching FNA's signature exactly) but sums/XORs its component hashes via an unsigned integer type (std::uint32_t wraparound), then casts back to int at the end Distinct from the std::size_t row above — used where the return type itself must stay int for FNA-signature parity (e.g. Microsoft::Xna::Framework::Input::Touch::TouchLocation::GetHashCode(), GamePadTriggers::GetHashCode(), MouseState::GetHashCode()). C#'s unchecked integer overflow (the implicit default for arithmetic in a struct's GetHashCode()) is well-defined wraparound; the same arithmetic in signed C++ int is undefined behavior on overflow, so the intermediate computation is done in an unsigned type (well-defined wraparound, bit-identical result) and only converted to int for the return value. Tracked project-wide as INPUT-BUILD-006; confirmed during Input Phase 1 (plan_input.md P1-003/P1-010/P1-018/P1-023)
TouchCollection::CopyTo(std::vector<TouchLocation>&, int) inserts at the given index (growing the destination) instead of overwriting pre-existing slots of a fixed-size array like FNA's List<T>.CopyTo(T[], int) C++'s destination is a growable std::vector, not a fixed-size array — there is no C++ equivalent of "throw if the fixed array isn't big enough past arrayIndex" for a container that can simply grow. Out-of-range arrayIndex itself still throws std::out_of_range, matching FNA's ArgumentOutOfRangeException intent. Confirmed during Input Phase 1 (plan_input.md P1-022)
Model::Draw() silently defaults a mesh's parent-bone index to 0 when ModelMesh::ParentBone is nullptr, instead of FNA's mesh.ParentBone.Index (a NullReferenceException on null) Found during the Task 431 Model/ModelMesh/ModelBone audit (plan_graphics.md). A null-pointer dereference in C++ is undefined behavior, not a catchable exception like C#'s NullReferenceException — silently defaulting to bone 0 is a strictly safer failure mode than UB, at the cost of no longer matching FNA's crash-on-invalid-model behavior exactly. No test currently exercises or depends on this specific fallback
ModelMesh::Draw() silently skips a ModelMeshPart whose Effect is nullptr (checking PrimitiveCount > 0 alone, like FNA, is not sufficient — CNA also requires a non-null Effect), instead of FNA's effect.CurrentTechnique (a NullReferenceException on a null Effect) Found during the Task 431 Model/ModelMesh/ModelBone audit (plan_graphics.md) — corrects an earlier, incorrect claim in Task 728's own write-up that this skip "matches FNA's own ModelMesh.Draw"; it does not, but is kept as a strictly safer failure mode than a null-pointer dereference, same reasoning as the Model::Draw() row above. Task 728's own test relies on a real, non-null Effect, so this correction doesn't invalidate that task's own findings
Model::CopyBoneTransformsFrom/CopyBoneTransformsTo both loop over Bones.Count (the model's own bone count), not the caller-supplied array's length like FNA does Found during Task 436's test design (plan_graphics.md). FNA's real implementation loops for (int i = 0; i < sourceBoneTransforms.Length; ...) (the caller's array length, not Bones.Count) for both methods — so a caller-supplied array LARGER than Bones.Count makes FNA throw partway through the loop (Bones[i] for i >= Bones.Count throws via ReadOnlyCollection<T>'s own indexer), even though the method's own upfront length check only guards against arrays that are too SMALL. CNA loops by Bones.Count for both methods instead, so a larger-than-needed array is simply accepted with its extra elements ignored/untouched — a safer, deliberately-kept deviation from FNA's own more fragile behavior, not a bug, matching the same class of intentional C++ safety improvement as the Model::Draw()/ModelMesh::Draw() rows above
Audio: 3D positional audio is pan + distance-attenuation + Doppler pitch shift only, no elevation (Mix_SetPosition) or true HRTF SDL_mixer has no FAudio F3DAudio equivalent for elevation/HRTF; distance attenuation and Doppler are both real, exact closed-form computations (plan_audio.md P9-3D-003/004/005) needing no native 3D audio API, unlike elevation/HRTF
Audio: Apply3D's pan approximation now projects the emitter's relative position onto the listener's own right axis (Cross(Forward, Up), normalized, via SoundEffectInstance::INTERNAL_calculateListenerRight()) instead of raw world-space X displacement, so turning the listener changes which side an emitter pans to, matching real X3DAudio's own listenerBasis.right projection (F3DAudio.c's ComputeEmitterChannelCoefficients) For the default orientation (Forward=(0,0,-1), Up=(0,1,0)), Cross(Forward, Up) reduces to exactly Vector3.Right, so this is a strict generalization of the old world-X-only approximation, not a behavior change for an unrotated listener (the common case). Still an approximation, not a port of X3DAudio's full multi-speaker energy-diffusion/azimuth pipeline (SDL3_mixer forces 2-channel-only output; CNA's own stereo-only crossfeed matrix, CP-19, has no N-speaker equivalent) -- only the listener's orientation is used; the emitter's own Forward/Up remain unread, matching real X3DAudio too (emitter orientation only affects multi-channel emitter configurations there, not the pan of CNA's mono point-source approximation) (plan_audio.md P9-3D-010)
Audio: GetHashCode() uses std::hash on the category/cue name, doesn't match C# String.GetHashCode() Platform C++ hash instead of .NET's algorithm; internal consistency preserved
Audio: streaming WaveBank ctor's offset/packetSize parameters are unused Matches FNA's own WaveBank.cs, which never forwards them to FACTStreamingParameters either (only .file is set); real per-entry lazy disk reads are implemented (plan_audio.md T-3F)
Audio: SoundEffect is move-only (copy ctor/assignment deleted) Required so its instance-tracking + Dispose-cascade (matching FNA's SoundEffect.Instances) has a single, unambiguous owner per resource (plan_audio.md T-3G)
Audio: ContentManager::Load<Audio::SoundEffect>() never caches instances (always a fresh load) SoundEffect's move-only, per-owner Dispose-cascade semantics make cross-caller sharing actively wrong, not just impossible (plan_audio.md T-3G)
Audio: SoundEffectInstance::INTERNAL_applyReverb is a documented no-op SDL3_mixer has no aux-send/return bus (no equivalent to FAudio's shared ReverbVoice); low/high/band-pass filters are implemented for real via a state-variable filter run in an SDL3_mixer per-track callback (plan_audio.md T-4C)
Audio: hard-panning a stereo source (Pan = ±1, or Apply3D with the emitter hard left/right, via SoundEffectInstance; or the static fire-and-forget SoundEffect::Play(volume, pitch, pan) helper) now correctly blends both channels into the favored speaker instead of eliminating the opposite channel outright, across every entry point (P11-PAN-001/P11-PAN-002, RFC-1 -- SoundEffectInstance::Play()/Apply3D()/the Pan setter route through a shared cooked-callback DSP state; the fire-and-forget path has its own smaller, independent per-track pan state, but reuses the identical matrix math) RFC-1's own design (plan_audio.md P10-PAN-003): SDL3_mixer's own MIX_SetTrackStereo gain is fixed to unity and CNA applies a real 4-coefficient crossfeed matrix (matching FNA's SetPanMatrixCoefficients exactly, SoundEffectInstance::INTERNAL_calculatePanCrossfeedMatrix) in a per-track cooked callback, filter-then-crossfeed in sequence on the same buffer for the SoundEffectInstance path (T-4C filter). Verified not to regress the filter (ConcurrentFilterUpdatesDoNotRaceWithRealMixingThread re-run clean under ThreadSanitizer, 10x plus the full Audio-scoped suite, zero races); mathematically proven equivalent to FNA's separate mono-source formula when fed a duplicated-mono signal, so no separate mono branch was needed anywhere (plan_audio.md P11-PAN-001). The fire-and-forget path's own cooked-callback state must NOT be freed synchronously from SDL3_mixer's "track stopped" callback -- a real, ASan-caught heap-use-after-free, since SDL3_mixer can still deliver a track's already-pulled final audio buffer to the cooked callback after the stopped callback already ran; fixed by deferring that free to the next fire-and-forget Play() call (plan_audio.md P11-PAN-002)
Audio: XACT category instanceLimit/maxInstanceBehavior/fadeInMS/fadeOutMS are now enforced for real (AudioEngine::CheckCategoryInstanceLimit(), called from Cue::Play()) -- FAIL rejects the new cue outright; REPLACE_LOWEST_PRIORITY evicts the active same-category cue with the lowest XsbSound::priority; QUEUE/REPLACE_OLDEST/REPLACE_QUIETEST are all treated as "evict the oldest active cue in the category" Real FAudio's own handle_instance_limit() (FACT_internal.c) carries a FIXME: How does QUEUE differ from REPLACE_OLDEST? comment and treats both identically, and its REPLACE_QUIETEST branch is an unfinished stub that (despite the name) just keeps overwriting the victim with whatever cue it last saw -- i.e. it also behaves like REPLACE_OLDEST in practice. CNA matches FAudio's real shipped behavior rather than implementing a "more correct" quietest-by-volume search FAudio itself never does. Category fade in/out is applied exactly where real FACT applies it -- only as part of this instance-limit replacement (a fading victim via Cue::ForceFadeOutForInstanceLimit(category.fadeOutMS), the new cue fading in via category.fadeInMS, both reusing the Cue::ReconcileState() wall-clock ramp P9-STOP-010 already added) -- AudioCategory::Pause/Resume/SetVolume/Stop remain instantaneous with no fade, matching real FACT exactly (category fadeInMS/fadeOutMS are never referenced anywhere else in FACT_internal.c/FACT.c). Cue-level instanceLimit/fadeInMS/fadeOutMS/maxInstanceBehavior (from a complex .xsb cue's own fields) are now also enforced for real, via AudioEngine::CheckCueInstanceLimit(), called from Cue::Play() before the category-level check above, matching FACT_internal.c's play_sound() order exactly (plan_audio.md P9-CATEGORY-011)
Audio: a cue-level instanceLimit eviction (AudioEngine::CheckCueInstanceLimit()) picks its victim from every live cue in the whole SoundBank, with no filter by category or by the triggering cue's own definition -- so it can evict a completely unrelated cue instead of another instance of the same named cue Matches FACT_internal.c's handle_instance_limit(cue, NULL) exactly: its victim-search loop only ever filters by category when a non-NULL category is passed in, which never happens for a cue-level check -- this looks like a genuine oversight in FAudio itself (a cue-level instanceLimit conceptually ought to only compete against other instances of the same named cue), but CNA replicates it exactly rather than "fixing" upstream FAudio's own shipped behavior (plan_audio.md P9-CATEGORY-011)
Audio: a category's authored base volume (XgsCategory::volume) applies by default, without needing an explicit AudioCategory::SetVolume() call first (AudioEngine::Init() seeds categoryVolumes[i] = xgs.categories[i].volume, not 1.0f) Real FAudio literally initializes every category's currentVolume to a hardcoded 1.0f regardless of its own authored volume field (FACT_internal.c:2098, FACT.c:290/302/314) -- meaning the authored base volume never actually takes effect until the game calls SetVolume() at least once, in real FACT. CNA deliberately keeps its own, more useful default (matches a sound designer's authoring intent even if the game never calls SetVolume) rather than reproducing this specific upstream quirk; the two are consistent at the one boundary that matters (SetVolume(1.0) reproduces the authored default either way). SetVolume()'s own formula (multiply the argument by the authored base, then recursively cascade to child categories) still matches FACT exactly, P12-CATEGORY-001 (plan_audio.md)
Audio: Cue::getIsCreatedProperty()/getIsPreparingProperty() are permanently unreachable -- always return false CNA's .xsb parsing is synchronous, so a Cue skips FACT's CREATEDPREPARINGPREPARED phases entirely; Cue::state_ is initialized straight to Prepared in the constructor and never regresses to either earlier value. Real FACT can observe a cue genuinely in the CREATED/PREPARING states between an async FACTSoundBank_GetCue call and its background wave-bank preparation completing; CNA has no equivalent async preparation step to model (plan_audio.md P10-AUDIT-002/003)
Audio: AudioEngine never throws NoAudioHardwareException from its own constructor (it always reports exactly one renderer — SDL3_mixer is compiled in — so FNA's "zero renderers" check can never fail here); NoAudioHardwareException is still thrown at the actual point of failure, when the SDL3_mixer device itself won't open (SoundEffect/DynamicSoundEffectInstance's GetMixerOrThrowXna(), plan_audio.md P9-HARDWARE-002) CNA has exactly one audio backend (SDL3_mixer) with no renderer-enumeration API to ever report zero of; matching FNA's dead code path exactly would require fabricating a "no renderers" condition that cannot occur in this environment (plan_audio.md XA-9, P9-HARDWARE-003)
Audio: SoundBank/WaveBank (non-streaming) constructors silently stay in a "stub"/unprepared state on an existing-but-corrupt .xsb/.xwb file instead of throwing Matches FNA exactly: SoundBank.cs/WaveBank.cs never check FACTAudioEngine_CreateSoundBank/CreateInMemoryWaveBank's return code either, so corrupt-but-present bank content never throws a catchable C# exception in FNA (plan_audio.md P9-HARDWARE-003). A missing file, by contrast, now throws System::IO::FileNotFoundException from all three ctors (AudioEngine, SoundBank, non-streaming WaveBank), and an existing-but-corrupt AudioEngine settings file now throws System::InvalidOperationException("Engine initialization failed!") — both match FNA's TitleContainer.ReadToPointer/checked FACTAudioEngine_Initialize return code precisely (plan_audio.md P9-HARDWARE-003)
Audio: Cue::Stop(AsAuthored)'s release is driven by a real, retained, authored fadeOutMS (XactParser now retains it into XsbCue, P9-STOP-010) -- a real linear volume ramp over that exact duration, then a hard stop, matching FACT's SOUND_STATE_FADE_OUT handling (FACT_INTERNAL_UpdateSound, FACT_internal.c) closed-form and exactly, ticked both lazily (every state getter) and per-frame (AudioEngine::Update()). A cue with no authored fadeOutMS gets a real RPC-only release tail instead when a "ReleaseTime"-bound RPC curve exists (maxRpcReleaseTime, P10-RPC-004, mirroring FAudio's SOUND_STATE_RELEASE_RPC); only a cue with neither an authored fadeOutMS nor a qualifying RPC curve is an immediate stop, matching FACT's own FACTCue_Stop condition (fadeOutMS == 0 && maxRpcReleaseTime == 0 forces the immediate path) exactly -- a "simple" cue's format has no fadeOutMS field at all, so it can only ever get a tail via the RPC-release path (plan_audio.md P9-STOP-010, P10-RPC-004)
Audio: XACT RPC (Runtime Parameter Control) volume/pitch curves are now re-evaluated continuously, every AudioEngine::Update() tick (via Cue::ReconcileState()), instead of only once at Cue::Play() time Matches real FACT (FACT_INTERNAL_UpdateRPCs/FACT_INTERNAL_UpdateEngine, FACT_internal.c) recomputing every active track's bound RPC curves on every FACTAudioEngine_DoWork tick, so a variable that changes mid-playback now continuously updates volume/pitch, matching real XNA/FNA. The built-in "AttackTime"/"ReleaseTime" envelope variables and RPC-targeted filter frequency/Q are now also live/continuous this same way (P10-RPC-002/003/004, P10-FILTER-002/003/004/006 -- see those rows/plan_audio.md's Phase 10 section); the only RPC target that still isn't evaluated at all is a DSP preset (parameter >= RPC_PARAMETER_COUNT), a different kind of gap (no DSP preset system exists, not a continuity issue -- see that row below). As a side effect of the original fix, the fade-out/fade-in wall-clock ramps (P9-STOP-010/P9-CATEGORY-007) also fold in the freshly-evaluated RPC volume multiplier -- previously they recombined only baseVolume*categoryVolume*fadeMultiplier, silently dropping whatever RPC multiplier had been baked in at Play() time the moment a fade began, a real (if narrow) gap independent of one-shot-vs-continuous RPC (plan_audio.md P9-XACT-016)
Audio: DynamicSoundEffectInstance's constructor performs no validation of sampleRate/channels (accepts values below 8000 Hz, above 48000 Hz, zero, or negative without throwing) MSDN documents an 8,000-48,000 Hz range with ArgumentOutOfRangeException otherwise, but real FNA's own constructor (DynamicSoundEffectInstance.cs) performs zero validation either — a straight field-assignment into a FAudioWaveFormatEx. CNA matches FNA's actual (undocumented, permissive) behavior rather than retrofitting MSDN's stricter documented contract, consistent with the identical precedent already established for SoundEffect's own constructors (P9-VALIDATION-001). Decision made and locked down by tests during the plan_audio.md Phase 10 audit (P10-DYN-001/002/003)
Audio: a complex sound's per-track low/high/band-pass filter (filterData/frequency in the .xsb) is wired for real into SoundEffectInstance::INTERNAL_apply{Low,High,Band}PassFilter at Cue::Play() time (the base, XACT-authored value -- one-shot, matching real FACT's own equivalent, which also never re-applies an unmodified base value on its own); a live filter-frequency/filter-Q RPC (RPC parameter 3/4) now overrides it continuously every tick via SoundEffectInstance::INTERNAL_applyRpcFilterOverride (P10-FILTER-002/003/004/006), matching real FACT's unconditional per-tick FAudioVoice_SetFilterParameters exactly -- no remaining deviation for this RPC target INTERNAL_apply{Low,High,Band}PassFilter has a CNAEXT-only oneOverQ parameter (default 1.0f, so FNA's own hardcoded-1.0f public behavior is unchanged for every other caller) so the real parsed XACT Q-factor byte, and a live RPC-Q override, both have a place to go. Sound-level SOUND_FLAG_HAS_DSP remains a no-op — confirmed by audit (plan_audio.md P9-XACT-010) to be FACT's reverb-send-enable flag, not a filter selector, so this is unrelated to INTERNAL_applyReverb's no-op status above (plan_audio.md P9-XACT-011/012/013, P10-FILTER-002/003/004/006)
Audio: RPCs targeting a DSP preset (parameter >= RPC_PARAMETER_COUNT) are parsed as curve targets but never evaluated against anything No DSP preset system (a DSP effect chain with its own addressable parameters) exists in CNA at all -- unlike filter frequency/Q (now live/continuous, P10-FILTER-002/003/004/006) or the "AttackTime"/"ReleaseTime" envelope variables (now live/continuous, P10-RPC-002/003/004), a DSP preset RPC target has no destination to write to regardless of continuity (plan_audio.md P9-XACT-006's note, P11-CHECKLIST-001)
Audio: a parsed per-track filter's type can only ever come out as low-pass or high-pass, never band-pass, even though the format has a band-pass value Replicates FAudio's own bit-decode of the filterData field exactly ((filterData>>1)&0x02 structurally can only yield 0 or 2); this looks like a genuine upstream FAudio quirk, not a CNA bug — deliberately not "corrected" so CNA stays byte-for-byte behaviorally identical to real FACT content (plan_audio.md P9-XACT-010/011)
Audio: PlayWaveEffectVariation/PlayWaveTrackEffectVariation per-play pitch/volume/filter-frequency/Q randomization only ever resolves once, at a fresh Cue::Play() call ("first activation"), never redrawing on a later loop iteration re-triggering the same event, and never applying the separate _ADD/_NEW_ON_LOOP combination bits (which only affect that later-iteration case) Same root cause/precedent as PlayWaveTrackVariation's own "first activation" row above -- CNA's whole-sound-level event model has no per-frame XACT event-scheduling/re-triggering system for a later iteration to ever reach (plan_audio.md P11-XACT-003)
Audio: PlayWaveTrackVariation/PlayWaveTrackEffectVariation selection (Ordered/OrderedFromRandom/Random/RandomNoRepeats/Shuffle) only ever resolves once, at a fresh Cue::Play() call ("first activation"), not on every later loop iteration a real, still-active FACT sound instance would re-run CNA's whole-sound-level event model has no per-frame XACT event-scheduling/re-triggering system (a Cue resolves exactly one wave reference per track per Play(), matching the pre-existing "first PlayWave event" simplification) -- implementing true per-loop-iteration re-selection would need that larger scheduling rewrite, not a change local to the selection algorithm itself (plan_audio.md P11-XACT-002)
Media: Album/AlbumCollection/Artist/ArtistCollection/Genre/GenreCollection/Picture/PictureCollection/PictureAlbum/PictureAlbumCollection/Playlist/PlaylistCollection/MediaLibrary/MediaSource are a real, from-scratch local media-library implementation, not a port of any FNA logic FNA's own equivalents are 100% NotImplementedException stubs on every platform, permanently -- there is no upstream behavior to match. All design decisions (scan roots, tag parsing, on-disk playlist format, tree construction, thread-safety, case-insensitive Artist/Genre dedup) were made independently and are recorded in plan_media.md §4 (D1-D11), not derived from FNA (plan_media.md MEDIA-46..69)
Media: the local media library scans real per-OS folders through IPlatformFileSystem::GetUserFolder(Music/Pictures), with a CNAEXT SetMusicRootEXT/SetPictureRootEXT override for tests/config No FNA precedent (see the row above); SDL3 resolves the OS folder natively while HEADLESS/TERMINAL read user-dirs.dirs, so media code itself stays platform-neutral (plan_media.md D1, MEDIA-46; plan_platform.md PLAT-112)
Media: song tags are read via a minimal, internal, from-scratch parser (CNA::Internal::Media::AudioTagParser) -- real Ogg Vorbis-comment blocks for .ogg, real ID3v2.3/2.4 text frames (with the full text-encoding-byte matrix: Latin-1/UTF-16 w/BOM/UTF-16BE/UTF-8) for .mp3, folder/filename heuristics as a fallback for .wav/untagged/unsupported files -- instead of a third-party tag library (e.g. taglib) Same "write a minimal from-scratch parser instead of a new third-party dependency" precedent already established by XactParser in Audio (plan_media.md D2, MEDIA-47..51)
Media: Playlist is backed by real M3U/M3U8 files on disk (.m3u8 UTF-8, .m3u local/legacy encoding, otherwise identical); PlaylistCollection scans the Music root for *.m3u/*.m3u8 XNA itself defines no on-disk playlist format at all -- a free choice constrained only by "pick something real users' tools actually produce" (plan_media.md D5, MEDIA-57/58)
Media: MediaLibrary's scan is a synchronous, point-in-time snapshot taken once at construction -- no live filesystem watching; a CNAEXT Refresh() would be a reasonable future addition but doesn't exist yet Matches the real Zune/Xbox 360 "library" concept, which is also a snapshot, not a live filesystem watcher; XNA's own API shape surfaces no live-update requirement either (plan_media.md D6, MEDIA-62)
Media: a single FFmpeg-based CNA::Internal::Media::VideoDecoder replaces FNA's two separate native decoders (dav1dfile for AV1, Theorafile for Theora) plus FNA's shader-based (BaseYUVPlayer/Effect) YUV→RGBA blit; YUV→RGBA conversion runs on the CPU in C++ instead of via a pixel shader Deliberate, CLAUDE.md-documented choice (does not depend on libswscale headers). The public contract is preserved exactly: GetTexture() -> Texture2D, pull-based frame pacing driven by the caller (matches FNA's own Stopwatch-in-GetTexture() design), the Duration hack, IsLooped, clamped Volume -- confirmed by reading VideoPlayerAV1.cs/VideoPlayerTheora.cs directly, not assumed (plan_media.md §2 item 2)
Media: VideoSoundtrackType is metadata-only -- stored and returned, never used internally to affect volume or ducking Matches FNA exactly: confirmed by reading every FNA Video-namespace file, nothing in VideoPlayer/BaseYUVPlayer/VideoPlayerAV1/VideoPlayerTheora ever branches on it either. Already faithful to FNA, not a gap (plan_media.md §2 item 5, MEDIA-31)
Media: VideoPlayer::Dispose() is kept idempotent (a second call is a safe no-op) rather than replicating FNA's own real VideoPlayer.Dispose(), which calls checkDisposed() and throws ObjectDisposedException on a second explicit Dispose() call FNA's own ~VideoPlayer() (finalizer) unconditionally calls Dispose() too -- replicating FNA's literal double-Dispose()-throws behavior in C++ would mean a second explicit Dispose() followed by normal destruction throws from inside the destructor, which is undefined behavior in C++ (destructors are implicitly noexcept), not just a surprising API quirk as it is in C#. CheckDisposed() is still applied to every other public VideoPlayer method (plan_media.md MEDIA-43)
Media: VideoPlayer::GetTexture() returns nullptr when called before any Play() call, instead of replicating FNA's real VideoPlayer.GetTexture(), which dereferences its own backing implementation unguarded (a raw NullReferenceException in real XNA/FNA for this exact case) Not a bug to silently improve there in FNA's own terms (FNA's own behavior is "real" C# code, just one that throws on misuse) -- C++ has no safe equivalent to "let it segfault/UB the way a managed runtime lets an NRE happen and get caught," so a graceful nullptr return is the deliberate, documented choice instead (plan_media.md MEDIA-45)
Media: AV1-container video content with a real audio track plays that audio track like any other container, instead of being permanently muted FNA's own VideoPlayerAV1 hardcodes IsMuted/Volume to do nothing at all (getters always return false/0.0f; the constructor even assigns IsMuted = true into a property whose setter is a no-op -- a real, dead-code FNA quirk), since Dav1dfile is video-only with no audio-track concept at all. CNA's unified FFmpeg VideoDecoder has no such per-codec split -- more capable than FNA, not a bug to "correct" by artificially muting AV1 videos (plan_media.md §2 item 3, MEDIA-37, MEDIA-89)
Media: Song::GetHashCode() is content-based (a hash of the resolved handle), where FNA's is identity-based (base.GetHashCode()) FNA's own choice here is arguably a latent bug -- two Songs that are Equals-equal by handle can have different hash codes in FNA, violating the usual Equals/GetHashCode contract. CNA's existing content-based hash is kept as a deliberate, beneficial deviation rather than "fixed" to replicate FNA's own inconsistency (plan_media.md §2 item 4, MEDIA-14)
Project-wide: out-of-range indexer exception type is a genuinely mixed precedent, not resolved consistently everywhere BoundingBox.cpp/VertexBuffer.cpp/NetworkSessionProperties.cpp all throw System::ArgumentOutOfRangeException directly; Input::Touch::TouchCollection.cpp (the closest structural analog to Media's MediaQueue/SongCollection -- a read-only indexed wrapper over an internal list) deliberately throws std::out_of_range instead, via an inline comment that was never promoted to this table until now. MediaQueue/SongCollection follow the majority precedent (ArgumentOutOfRangeException, MEDIA-11/12); TouchCollection's outlier is flagged here as a known, unresolved inconsistency -- not fixed as part of plan_media.md since it's outside that plan's Media-namespace scope (see NEXTmedia.md) (plan_media.md §2 item 7)
Media: MediaSource::GetAvailableMediaSources() returns only a LocalDevice entry; WindowsMediaConnect devices are never discovered The MediaSourceType enum itself is complete (both LocalDevice = 0 and WindowsMediaConnect = 4 exist, matching XNA), and all 4 of MediaSource's XNA members are present -- what is absent is discovery of WMC devices, an Xbox 360 / Windows Media Player-era streaming concept with no meaningful desktop equivalent. Returning exactly one real local source is the correct desktop behavior, not an unfinished feature (plan_media.md MEDIA-212)
Media: Song::Rating conversion scales are CNA decisions, not XNA/FNA-defined XNA exposes a 0-10 rating but defines no mapping from any file format. ID3v2 POPM is 0-255 with 0 reserved for "unrated", mapped here as 1-255 -> 1-10 rounded. The Vorbis RATING comment has no standard at all -- taggers variously write 0-100, 0-5 or 0-10 -- and is interpreted here as 0-100, the most common convention; non-numeric values are ignored rather than guessed at. IsRated means "a real rating tag was present", deliberately NOT Rating != 0, since both formats reserve 0 for unrated (plan_media.md MEDIA-182/183/184)
Media: visualization magnitude scale and thumbnail size are CNA choices XNA documents neither. VisualizationData::Frequencies magnitudes are scaled by 2/N (a full-scale sine reads ~1.0 in its bin) with the Hann window's own 0.5 coherent gain deliberately left uncompensated; GetThumbnail() fits within a 128px longest edge, preserving aspect ratio and never upscaling. Both are documented choices rather than claims of bit-exact XNA parity (plan_media.md MEDIA-187/209)
Media: .m4a/.aac files are deliberately NOT indexed by the media library The selected CNA audio backend's bundled mixer ships no AAC decoder at all (no decoder_aac.c, no SDLMIXER_AAC option), so such files cannot be played. Indexing them would advertise songs MediaPlayer::Play() could never play -- worse than omitting them. Revisit only if an AAC decoder is added to the mixer build (plan_media.md MEDIA-199/201)
Audio: a compact .xwb's non-last entry length is computed as the gap to the next entry's offset minus that entry's own deviation field; the last entry's length is the remainder of the wave-data segment with no deviation subtracted at all (XactParser.cpp's compact-entry parsing loop) Verified against the real, current FAudio source (FACT_internal.c's compact-entry parsing, ~line 3106-3124): the last entry's computation matches CNA's exactly (no deviation subtraction). FAudio's own non-last-entry computation in that same function is a genuine, long-standing bug (unchanged since at least a 2018-12-18 commit): it subtracts an entry's own just-computed offset from itself, always yielding zero, which would make every non-last compact-bank entry silent if actually reached -- CNA deliberately does not replicate that (same precedent as P11-XACT-004's discrete-lottery-bias fix: don't blindly replicate a confirmed reference-implementation defect). No code change was made here -- CNA's pre-existing behavior for both cases was already correct; the deviation from FAudio's own real source is intentional and beneficial (plan_audio.md AUD-11-001/002, 2026-07-17 deep audit)
Audio: WaveBank::GetSoundEffect returns nullptr for XMA/XMA2- and WMA-encoded entries (XwbFormat::XMA/XwbFormat::WMA), logging the bank name and a human-readable format name to stderr instead of decoding Both are proprietary codecs with no decode path anywhere in this stack -- SDL3 (CNA's only renderer) does not decode either format natively, unlike PCM/IEEE float/MS-ADPCM/IMA-ADPCM, which its own WAV loader handles (WavWrapper.hpp). A real, permanent capability gap, not a bug -- diagnostic quality was the only fixable part (plan_audio.md AUD-11-010/011, 2026-07-17 deep audit). Separately confirmed (AUD-11-009): IMA-ADPCM is not a distinct WaveBank mini-format tag at all (only PCM/XMA/ADPCM/WMA exist, FACT.h's FACT_WAVEBANKMINIFORMAT_TAG_*) -- ADPCM at the WaveBank level always means MS-ADPCM; IMA-ADPCM only matters for the separate XNB SoundEffectReader path

glTF 2.0 import — intentional divergences (GLTF-457)

Two different kinds, kept apart because they answer different questions. From the glTF specification: what CNA does not do exactly as §-numbered text requires, and why. From XNA: what CNA added to the XNA 4.0 model to carry glTF at all, every one a CNAEXT marker that went through docs/gltf-api-change-review.md's gate before it existed. What is lost rather than diverged — and the field that reports each loss at run time — is docs/gltf-limitations.md; this table is only the deliberate differences.

Divergence from the glTF specification Reason
§3.7.2.1's flat normals merge faces whose unit normals agree to within ~0.081° Flat shading gives a vertex one normal per face, and GLTF-461 does duplicate a vertex shared between differently oriented faces — the earlier claim that duplication was impossible because it changes every per-vertex stream was wrong; it is one remap applied to every stream. What remains is a tolerance, and it is a reproducibility floor rather than a smoothing threshold: two mathematically coplanar triangles whose cross products differ in the last bits must not change the vertex count, or the generated corpus stops being reproducible. Merged vertices take the area-weighted sum and are counted in flatNormalMergedVertexCountEXT (zero for every corpus asset) (GLTF-461)
§3.7.2.2's per-morph-target tangents are re-orthogonalised, not regenerated The section's tangent clause is a SHOULD — recompute with MikkTSpace against the updated positions, normals and texture coordinates. CNA re-orthogonalises the generated basis against the recomputed normal instead, which preserves the property tangent-space normal mapping depends on (T perpendicular to N, unit length, handedness untouched) without re-solving the UV gradients per pose. Reported through morphedFlatNormalsEXT (GLTF-461)
COLOR_0 is a multiplier on base colour in fifteen of the seventeen PBR renderers; the other two refuse such a draw §3.7.2.1/§3.9.2 make COLOR_0 a linear multiplier on base colour, and the importer, both loaders and both PBR effects carry it in full for rigid (stride 60, GLTF-462) and skinned (stride 80, GLTF-463) primitives — no attribute combination costs a primitive its material model. What diverges is renderer breadth, and it diverges safely: a renderer that does not evaluate the product calls the shared RequireVertexColourPbrSupportEXT and refuses the draw by name rather than substituting the opaque-white identity, which would be a visibly wrong surface reported as success. An uncoloured primitive is unaffected, and an application can opt into the identity deliberately with VertexColorEnabledEXT = false. Each open renderer's specific blocker is named in plan_gltf.md GLTF-465 and docs/gltf-renderer-pbr-fallbacks.md; four machine-checked inventories keep the partition honest (GLTF-462, GLTF-463, GLTF-465) — and because all four read declarations rather than reachability, GLTF-472 adds two more after finding two renderers whose complete shaders sat behind a draw route that never selected them: EveryStrideGatedPbrRouteAdmitsBothColourCarryingStrides pins each stride-gated PBR route's acceptance predicate, and RendererStrideConformance.AColourCarryingPbrPrimitiveEitherDrawsOrRefusesByName settles it from a live draw. An explicit refusal counts as one only if it happens before any incompatible vertex-layout interpretation and before any GPU submission — "throws eventually" is not "did not read the data", and a route that binds an array or records a command first has already half-executed the draw and left state the next frame inherits (GLTF-473)
TRIANGLE_STRIP, TRIANGLE_FAN and LINE_LOOP are converted to lists at import instead of drawn as authored The conversion is exact — the same triangles in the same winding — and it keeps four topologies out of every renderer's state. The source mode is still carried to L3, so the conversion is checkable rather than assumed (GLTF-081, docs/gltf-conventions.md §10.1)
alphaMode: BLEND and doubleSided are carried, not applied Both are per-draw device state in XNA — BlendState and RasterizerState::CullMode — which an application sets. Having Model::Draw mutate the device as a side effect of drawing would be a global change no XNA application expects. MASK is applied, because a cutoff is fragment-program work rather than device state (GLTF-230/GLTF-231/GLTF-372, review §1.3/§1.4)
A material sampling three distinct UV sets falls back for the third CNA's PBR layouts carry two packed channels and every map selects either independently. A third authored set has no vertex slot, so the affected maps fall back to channel 0 and are named (GLTF-182, GLTF-188). Per-map KHR_texture_transform matrices remain a separate named limitation (GLTF-184, GLTF-336)
The metallic-roughness roughness is clamped to [0.045, 1] in the shader Below ~0.045 the GGX distribution's denominator collapses and the specular highlight becomes a numerically unstable point. A documented, deliberate clamp rather than the spec's open [0,1] (GLTF-378)
A file that requires an extension CNA approximates is refused, not loaded extensionsRequired is normative: the author has declared the file cannot be interpreted correctly without it. KHR_materials_transmission is approximated and not claimed for exactly this reason, while KHR_lights_punctual is approximated and claimed, because refusing every lit file would be far worse (GLTF-333, GLTF-334)
Several malformed-but-parseable files are refused rather than repaired: a non-monotonic animation sampler input, an accessor violating §3.6.2.4 alignment, a byteStride that is not a multiple of 4, an accessor count whose byte span overflows Repairing produces a plausible wrong answer that plays or renders, which is the failure mode this whole campaign exists to prevent; sorting a backwards sampler input, for instance, re-pairs each time with a value the exporter did not write. Equal adjacent times are deliberately not refused — an exporter writes them for a hard cut (GLTF-021, GLTF-036, GLTF-039, GLTF-313)
PbrEffect does not premultiply alpha into RGB, unlike CNA's other stock effects glTF's baseColorFactor keeps albedo and alpha independent: alpha affects coverage, never the lit RGB response. Matching the other effects here would darken every transparent PBR surface (GLTF-369)
Two decode faults in the vendored cgltf are worked around CNA-side rather than patched upstream-first Sparse values are read at the base accessor's stride instead of tightly packed, and §3.6.2.2's max(c/N, −1) clamp is omitted for signed normalized components (−128 decodes to −1.0079). Both workarounds are pinned to the vendored behaviour so an upgrade retires both copies (GLTF-056, GLTF-062, known_bugs.md)
Divergence from XNA 4.0 Reason
ModelMeshPart::PrimitiveTypeEXT (CNAEXT) XNA carries the topology as an argument to DrawIndexedPrimitives, so every XNA ModelMeshPart is implicitly a triangle list. A glTF line or point primitive has nowhere else to live, and the alternative was leaving those topologies rejected at import (GLTF-073)
ModelMeshPart::PrimitiveCount means "primitives of this part's topology", not "triangles" The value is unchanged for every triangle-list part, which is what every XNA part is; the meaning generalises rather than changing (GLTF-078)
AlphaModeEXT, AlphaCutoffEXT, DoubleSidedEXT on PbrEffect/SkinnedPbrEffect (CNAEXT) XNA has no material-level alpha coverage at all: transparency is a BlendState and cutout rendering is AlphaTestEffect::ReferenceAlpha. glTF makes both properties of the material, so they need somewhere that travels with the material (GLTF-228/GLTF-229/GLTF-231)
IorEXT, SpecularFactorEXT, SpecularColorFactorEXT on PbrEffect/SkinnedPbrEffect (CNAEXT) XNA has no PBR material or dielectric Fresnel parameters. glTF's extensions make them runtime shading inputs, so they live beside metallic/roughness on the effects and derive shader-ready F0/F90; renderer consumption is still a named residue (GLTF-343/GLTF-344)
Model::CamerasEXT / ModelCameraEXT (CNAEXT) XNA's Model has no cameras. A property rather than Tag, because Tag holds one object and SkinningData and ModelAnimationsEXT already contend for it — a skinned model with cameras would have had to choose (GLTF-317)
PbrEffect/SkinnedPbrEffect themselves, and the stride-48/68 vertex formats (CNAEXT) XNA 4.0 has no PBR effect and no tangent-carrying layout. Both are additions, not reinterpretations of an XNA type (plan_cnj.md Phase 13A)
A skinned KHR_materials_unlit material is approximated rather than mapped BasicEffect::LightingEnabled = false expresses unlit exactly; real XNA's SkinnedEffect has no such property, so the skinned case is approximated with an all-white ambient and no directional light — unlit apart from any specular term (GLTF-337)
Four joint influences per vertex, and at most three directional lights BlendIndices/BlendWeight carry four; XNA's lighting model is three directional lights plus ambient. Both are XNA's shape rather than a CNA choice, and both losses are counted rather than silent (GLTF-095, GLTF-325)