Architectural invariants

CNA snapshot 009d40f5  ·  Development › Invariants  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Each rule was checked by reading the named source; the tests listed are registered at 009d40f5 and were not executed. Where a rule is held only by declaration order or convention the page says so.

These are the constraints that follow from construction order, interfaces, tests or build policy in the CNA source, and that a well-intentioned patch can break without any test noticing. Each entry states the rule, the failure that violating it produces, the evidence read at snapshot 009d40f5 (function and test names, not just files) and, just as deliberately, what is not established: several of these rules are held by declaration order or by convention and by no assertion at all. A rule appears here only where the source confirms it; where the source is narrower or looser than the usual one-line statement, the corrected statement is used. Nothing was built or run for this page, and a named test is a test that is registered, not one that passed.

Which rules are enforced, and how

InvariantHeld byPinned by (present, not executed)Boundary of the guarantee
Platform outlives dependent membersMember declaration order and an install guard in GameGamePlatformOwnershipTest.* (install and uninstall behaviour)No test asserts the declaration order itself
Resources die before the native deviceThe step order of GraphicsDevice::Dispose(bool)GraphicsDeviceLifecycleTest, GraphicsDeviceDisposalHookTestRenderer-side deferred work is per renderer
Backend axes stay independentThree selection files, combination rules, a strict SDL gateConfigure-time CTest cases and the platform CI tuplesIndependent is not unconstrained
Input sees every event firstThe loop body of Game::PollEventsGameEventSemanticsGoldenTest transcriptOrder itself is read from source
Logical and drawable sizes differThe IPlatformWindow contract and GraphicsDeviceWindow, DPI and scale tests per backendMostly host-dependent
No present with a bound targetA check in both Present overloadsGraphicsDevicePresentRegionTestSkips where the renderer has no render targets
Pipeline needs no runtime deviceLink-closure gate; no device construction in pipeline sourcesCnaXnbDependencyBoundaryThe device-free property itself has no direct assertion
Callbacks never see destroyed stateDevice stop/close barriers, generation counters, handle checksDevice conformance, mixer, sensor and C API testsDocumented unsupported edges
Capabilities are truthfulFalse-by-default opt-in queries and device refusalsCapability tests, PlatformConformanceA default of true remains for older entries
The renderer choice latchesGraphicsRendererSelectionGraphicsRendererFallbackTestA header comment lags the code
SDL stays at declared edgesA strict configure-time ratchet with a budget of zeroContractIsSdlFreeTests, WaylandIsSdlFree and link-closure testsStatic scan, not a runtime property

Platform outlives dependent members

Rule. A Game owns its IPlatform (platform_, a std::unique_ptr), installs it as the process-wide platform from that member's own initializer, and declares it before every member that can reach a platform service. The platform is therefore constructed first and destroyed last, and its lifetime strictly contains that of the graphics device, the window, the content manager and the components.

Why, and what breaks. GraphicsDevice's constructor borrows CNA::Platform::GetCurrentPlatform() as its platform_, and the window wrapper, the input services, the GL-context, Vulkan-surface and presenter services, the file-system service that ContentManager uses and the devices layer's sensor and haptic backends all reach the same object, sometimes while being destroyed. (Audio playback devices are the exception: IAudioDevice is documented as owning a playback device independently of the window and input platform, and only title-content loading in the audio module touches the platform file system.) A violation shows up as an exit-only use-after-free, an invalid native window or context handle, or a callback after service shutdown; it is easy to miss because it happens at process teardown. A second failure mode is construction that throws halfway: ~Game never runs, so without a guard the process-wide accessor would point at a platform destroyed in the same unwind.

Evidence.

  • Game.hpp, private section: platform_ is declared first, under a comment stating that its lifetime has to contain the others' and that member order is the only thing that guarantees it; then platformInstallation_, platformCapabilities_, eventBatch_, Components_, GraphicsDevice_, Content_, Window_, LaunchParameters_ and Services_. Components_ is declared before GraphicsDevice_, so owned components are destroyed after the device.
  • Game.cpp: the Game(std::unique_ptr<IPlatform>) initializer list repeats that order, and its first entry is platform_(InstallPlatform(std::move(platform))). InstallPlatform refuses a null platform (std::invalid_argument), pushes onto a process-wide stack and calls SetCurrentPlatform. PlatformInstallation is an RAII guard armed right after that and disarmed as the constructor's last step, so exactly the failed-construction path uninstalls. ~Game calls Dispose(false) and then UninstallPlatform(platform_.get()) before any member is destroyed; the accessor is re-aimed at the previous live game's platform, and only if it still points at the departing one.
  • The stack and its mutex are heap-allocated and never destroyed, because a C API game handle can outlive them at process exit; the source comment records an AddressSanitizer finding for exactly that case.
  • CurrentPlatform.cpp: with nothing installed the ambient accessor lazily creates and owns a default platform, so a bare GraphicsDevice outside a Game keeps that default alive only until ResetCurrentPlatform; the borrowed platform must never be assumed to outlive its game.

Pinned by GamePlatformOwnershipTests.cpp: ExplicitPlatformIsOwnedAndInstalledBeforeGameMembers, AFailedConstructionLeavesNothingInstalled, DestroyingAGameUninstallsItsPlatform, AnInnerGameRestoresTheOuterOnesPlatformWhenItGoesAway, AnOuterGameOutlivingAnInnerOneDoesNotOverwriteTheInstallation and OutOfOrderDestructionLeavesNoDanglingInstallation. Not established. None of these asserts the member declaration order; that is source-verified and would be reported by a sanitizer build (see the destruction recipe) rather than by an assertion. Reordering members in Game.hpp is therefore a change that no unit test guards. The full ownership tree is in the ownership map and the teardown steps in Ownership and shutdown.

Graphics resources die before the native device and context

Rule. GraphicsDevice::Dispose(bool) disposes every registered resource while the renderer still exists, and only then tears down the renderer, the surface presenter and the window, and releases its video-subsystem reference.

Why, and what breaks. A neutral resource is not the native handle: Texture2D, buffers and render targets hold renderer-side allocations whose destruction needs the renderer, and on GL renderers a bound context. Destroying the renderer first produces driver errors, leaks or destructor crashes; disposing twice or from inside a Disposing handler produces work against half-torn-down objects.

Evidence. GraphicsDevice.cpp, Dispose(bool), in order: it sets isDisposed_ first (so re-entrant disposal is a no-op and a handler cannot issue work on a disposed device); raises Disposing only when disposing is true; moves resources_ into a local vector, clears the member and calls Dispose() on each entry (a resource's own RemoveResourceReference then finds nothing to erase); calls destroyNativeResources(), which invalidates the capability profile, resets renderer_, then surfacePresenter_, clears the shared text-input and mouse window handles if they name this window, and resets platformWindow_; and finally setVideoSubsystemAcquired(false), with a comment that the window goes first because the video subsystem backs it. ~GraphicsDevice calls Dispose() and then resets resourceDeviceLifetime_. A constructor that throws runs destroyNativeResources() and releases the video reference in its catch. On the resource side GraphicsResource.cpp registers in its constructor and keeps a std::weak_ptr of the device's lifetime token, checked in Dispose(bool) and in moves before the device is touched, so a resource destroyed after its device does not dereference it.

Pinned by GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTarget and RendererFacingOperationsRejectUseAfterDeviceDisposal (GraphicsDeviceValidationTests.cpp); GraphicsDeviceDisposalHookTest.OwnedResourcesAreStillDisposedThroughTheHook and TheDeviceIsAlreadyDisposedWhenDisposingIsRaised (GraphicsDeviceDisposalHookTests.cpp); the video-reference balance in GraphicsDeviceSubsystemLifecycleTest (GraphicsDeviceSubsystemLifecycleTests.cpp) and GraphicsDevicePlatformWindowTests.ConstructorFailureDestroysTheWindowBeforeReleasingVideo. Not established. The tests show that resources are drained, that the target unbinds and that references balance; none asserts that the drain precedes renderer_.reset(), which is read from the source. The weak token protects a late resource only from calling into a dead device: a resource that still owns a renderer-side object whose renderer is gone is a separate hazard, and backends that defer native frees (Vulkan retires handles into a fence-gated queue) need the extra question of whether any deferred command can still name an allocation after the drain. See Textures and render targets and the graphics ownership notes.

Backend axes remain independent

Rule. The platform (CNA_PLATFORM), the audio implementation (CNA_AUDIO_PLATFORM) and the renderer identity (CNA_GRAPHICS_RENDERER, optionally a list in CNA_GRAPHICS_RENDERERS) are three registries with three validators. None may be inferred from another, and every dependency between them must be an explicit rule that fails with a reason, never a silent substitution.

Why, and what breaks. Valid combinations are a cross product with holes, and code that quietly assumes a bundle (SDL windowing plus SDL audio plus a GL renderer) leaks SDL or native assumptions into HEADLESS, TERMINAL, X11, Wayland, Win32 and every non-GL renderer. The failure is a configuration that builds and then misbehaves on the host that was never the author's.

Evidence. PlatformSelection.cmake, AudioPlatformSelection.cmake and RendererSelection.cmake each validate their own axis and refuse a reserved or unknown value by name (the reserved platform values are SDL12 and EMSCRIPTEN; TERMINAL is reserved on Windows and WIN32 elsewhere). The cross-axis rules are written down and explicit: RendererSelection refuses any renderer but SOFTWARE, PORTABLEGL, HEADLESS or STUB under CNA_PLATFORM=TERMINAL before it probes any dependency; RendererCombinations.cmake states the platform partitions (Windows-only, Emscripten-only and macOS-only identities) and refuses combinations that cannot share a binary, with the reason; SdlAvailability.cmake makes CNA_ENABLE_SDL=OFF refuse, naming the selection, any choice that needs SDL (an SDL3 or SDL2 platform or audio, or SDL_RENDERER, SDL_GPU, FNA3D, FREEDIRECT). Defaults are per-host functions, not a bundle: Emscripten WEBGL2, Linux OPENGLES3, otherwise SDL_RENDERER. At run time GraphicsDevice asks the descriptor of the chosen family whether it needs a window and of what kind, and asks the platform for it through IPlatform; the platform interface names no renderer.

Pinned by the platform CI matrix in platform-ci.yml, whose graphical and headless tuples are SDL3 with OPENGLES3, SDL2 with OPENGLES3, SDL3 with VULKAN, SDL3 with SOFTWARE, HEADLESS with HEADLESS and NULL audio, and TERMINAL with SOFTWARE and NULL audio, plus a job with CNA_ENABLE_SDL=OFF, native X11, HEADLESS and NULL audio and another with X11, ALSA and four renderers compiled in; and by configure-time CTest cases registered in UnitTests.cmake: CnaAudioPlatformSelection_* (default, SDL3, SDL2, NULL, ALSA, the reserved OPENAL and WASAPI, and a bogus value), CnaSdl2OnlyRendererGate, CnaSdlOffFindsNoSdlPackage, CnaWaylandPlatformSelection, plus CnaRendererDefaultSelection_*, RendererCombinationRegistry and RuntimeRendererDiscipline from ModuleProbes.cmake. The platform contract itself is run once per compiled implementation by PlatformConformance and PlatformWindowConformance. Corrected. “Independent” does not mean unconstrained: the constraints above are part of the rule, and a new backend should add its constraint to the owning selection file, not to shared code. The value tables are in the generated Selection axes index, and the reasons in Renderer selection internals.

Input sees every platform event before game handling

Rule. In Game::PollEvents every PlatformEvent of the batch is passed to PlatformInputBridge::ProcessEvent first, in order, and only then does Game act on it (quit, close request, focus, resize, drop, lifecycle). After the whole batch the keyboard and mouse snapshot services advance once, and the gamepad and joystick services do so only when the Gamepad subsystem has been initialized.

Why, and what breaks. The frame's public snapshot and the input callbacks must reflect every transition of the batch even when the batch also asks the game to exit. If handling came first, a quit or close request could hide the release, wheel or text events queued behind it: a one-frame lag, a stuck key, a lost release adjacent to an exit.

Evidence. Game.cpp, PollEvents: it fills a caller-owned batch through platform_->PollEvents, then for each event calls PlatformInputBridge::ProcessEvent(event) with a comment that Exit() must not stop the rest of the batch from reaching the input state machine, then std::visits the same event for Game's own handling, and finally calls Update() on the keyboard and mouse services and, behind IsSubsystemInitialized(PlatformSubsystem::Gamepad), on the gamepad and joystick. That loop is the only production call site of ProcessEvent. There is no Game::UpdateInput at this snapshot: the snapshot update is the tail of PollEvents, which Tick, WaitWhileSuspended (a suspended mobile game) and the Emscripten frame body call.

Pinned by EveryImplementation/GameEventSemanticsGoldenTest.ObservableEventSemanticsMatchTheCapturedBaseline (GameEventSemanticsGoldenTests.cpp) against the checked-in transcript, whose cases include quit-then-wheel-in-one-frame, wheel-then-quit-in-one-frame and quit-then-focus-lost-in-one-frame; CNA's platform workflow runs that suite for every graphical tuple and for the headless tuple. Not established. The wheel totals in that test come from its own scripted mouse service, so the cases show that the snapshot update still runs after an exit request in the same batch; the bridge-before-handling order is what the source of PollEvents shows. Backend mappers and snapshot services are pinned by separate suites. The frame-level detail is on Input internals and One frame source trace.

Logical and drawable sizes are not interchangeable

Rule. A platform window reports its client area in logical units (GetClientBounds()), its drawable size in physical pixels (GetPixelSize()) and a scale (GetDisplayScale()); a renderer sizes its swapchain or framebuffer from the pixel size, never from the logical bounds, and any conversion between the two spaces is explicit and happens once.

Why, and what breaks. With high DPI the drawable size can exceed the logical size. Mixing them gives blurry output, clipped or letterboxed frames, resize loops and invalid swapchain extents; mixing them in input gives mouse positions in the wrong space.

Evidence. IPlatformWindow.hpp documents the three quantities, that the scale is always 1.0 on platforms without the HighDpi capability, and that window changes are asynchronous so Sync() blocks until they land. High DPI is opt-in per window: WindowDescription::highDpi is ignored where the capability is absent, and GraphicsDevice fills it from the family descriptor's wantsHighDpi (only the METAL descriptor sets it at this snapshot). GraphicsDevice::createRenderer and UpdateViewportFromWindow hand the renderer a RendererSurfaceInfo carrying drawableSize and displayScale; a window that refuses the query is logged and the renderer keeps its previous surface, while creation still fails. Game::PollEvents answers Resized, PixelSizeChanged and DisplayScaleChanged by refreshing GameWindow, calling UpdateViewportFromWindow and invalidating the renderer's surface, ignoring the event's own size payload. The requested-size path (applyPresentationParametersToWindow and GameWindow::EndScreenDeviceChange) is where SetSize is followed by Sync(). Input converts window-client positions into logical game coordinates through the renderer registered for the window, once, in Mouse.

Pinned by Sdl3WindowTest.PixelSizeIsReportedIndependentlyOfLogicalSize; PlatformWindowConformance.PixelSizeAndDisplayScaleAreSane and SizeChangeLandsAfterSync (PlatformConformanceTests.cpp); the Win32Dpi cases; X11Live.ResizingProducesBothALogicalAndAPixelSizeEvent and LogicalAndPixelSizeAgreeBecauseX11HasOnlyOneCoordinateSpace; WaylandProtocol.AFractionalScaleResizesTheBufferNotTheWindow and AnApplicationThatIsNotHighDpiIsLeftToTheCompositorToScale; WaylandLive.HighDpiWindowsFollowTheOutputsScale; and GraphicsDevicePlatformWindowTests.AViewportRefreshSurvivesAWindowThatRefusesItsDrawableSize. Not established. The conformance case only asserts that pixel size and scale are positive, not their relationship; no test checks that every renderer sizes its swapchain in pixels; the live high-DPI cases need a host that provides one and skip elsewhere. Read Debugging a native backend before changing a resize path.

Do not present a bound offscreen target

Rule. GraphicsDevice::Present is only valid on the backbuffer path: with a render target bound it throws System::InvalidOperationException (“Cannot present while render targets are bound”), and the same check guards the region and window overload.

Why, and what breaks. Presenting while an offscreen target is bound would present the wrong surface or an unresolved target, and each renderer would do something different. Making the check neutral keeps that divergence out of the backends.

Evidence. Both Present() and Present(sourceRectangle, destinationRectangle, overrideWindowHandle) in GraphicsDevice.cpp call ThrowIfDisposed(), then throw if renderTargetBound_; the ordinary path then takes a renderer context lease, calls renderer_->Present() and refreshes the viewport. renderTargetBound_ is set at the end of a successful SetRenderTargets and cleared when it is reset to the backbuffer. Related refusals in the same layer: an active render target cannot be bound to a texture slot, and Reset unbinds active targets.

Pinned by GraphicsDevicePresentRegionTest.PresentingWithRenderTargetsBoundIsRefused (GraphicsDevicePresentRegionTests.cpp), which checks both overloads and that unbinding makes presenting valid again, so the check does not latch; TextureCollectionValidationTest.ActiveRenderTargetCannotBindToPixelTextureSlot and GraphicsDeviceLifecycleTest.ResetUnbindsActiveRenderTargets. Not established. The present test skips on a renderer without RenderTarget2D support (for example STUB), so its coverage depends on the configured renderer. When a frame fails to appear, a target left bound at the end of Draw is the first thing to check (black-screen recipe).

Content pipeline does not require runtime devices

Rule. Import, process and write run as build-time tooling: no window, GPU device, audio device or renderer is created, and no build-time-only dependency (a font rasterizer, a media decoder, an external effect compiler) reaches a game's link closure.

Why, and what breaks. Content compilation must work in CI containers and on machines with no display or GPU, and a game must not link FreeType or FFmpeg because a pipeline exists. Break it and headless builds fail, authoring becomes host-dependent, and runtime binaries grow by build-time dependencies.

Evidence. modules/content-pipeline/CMakeLists.txt carries the build-time-only components (.spritefont through FreeType, MP3/WMA/WMV through the FFmpeg libraries, FBX binary arrays through zlib, the external effect compiler settings) and is linked by cna_content_compiler and nothing a game links (ToolContentPipeline.cmake). Reading the pipeline sources found no GraphicsDevice, platform or window construction under modules/content/src/Pipeline, modules/content-pipeline/src or tools/content (the only hits are comments), and CNA's own content pipeline document states that no pipeline component constructs a GraphicsDevice, opens an audio device, creates a window, reads back GPU data or initializes a renderer, with one recorded qualification: XNB float and ADPCM sound conversion can call SDL3's in-memory WAVE decoder when that audio backend is compiled, without initializing SDL video or audio or opening a device. That qualification is stale at this snapshot: XnbCanonicalData.cpp decodes float and ADPCM through CNA's own Audio::DecodeWavToPcm16, which is compiled into every audio implementation (its comment cites NPV-0104), so no SDL decoder is involved. The tool executable links cna_content, which itself links graphics core, audio and media libraries; it constructs none of their devices.

Pinned by CnaXnbDependencyBoundary (dependency_boundary.py, label content;xnb;architecture), which walks CMake's own link graph from the runtime and build-time roots and then inspects the built archives with nm, so a translation unit moved across the boundary is caught even when the graph still looks right; and by the HEADLESS content lane content-pipeline-windows-ci.yml, which configures CNA_PLATFORM=HEADLESS and CNA_GRAPHICS_RENDERER=HEADLESS, builds cna_content_tool (the cna-content executable) and CnaContentTests with MSVC, and runs a GoogleTest filter that excludes the runtime-load cases (*Runtime*, *MatchesRuntime*, *LoadsThrough*ContentManager*) before driving the command line; the lane runs on manual dispatch and on pushes to one named branch, not on next. Not established. No test asserts “no device was created” directly. Tests that verify a compiled asset by loading it back, such as the round trips in XnbContentPipelineTests.cpp, construct a GraphicsDevice for the runtime half; that is a load step, not a pipeline step, and it is why a pipeline change is validated on both sides (Modify the Content Pipeline).

Callbacks cannot observe destroyed state

Rule. Any audio, native or foreign callback needs an explicit stop, generation or ownership barrier before the data it reads is released, and the barrier must hold on the failure path as well as the normal one.

Why, and what breaks. A callback runs on a thread the caller does not control. Freeing its userdata, buffers or owner while it may still run gives nondeterministic use-after-free and, when a callback waits on a lock its own stopper holds, shutdown hangs.

Evidence, one barrier per boundary.

  • Audio devices. IAudioDevice.hpp: Open returns a paused device and retains shared ownership of the buffer callback until Close; Stop pauses and waits for an in-flight callback; after Close the callback is never invoked again; the callback must not block, allocate, throw or call lifecycle methods.
  • Mixers. The SDL3 mixer engine bumps an atomic generation in DestroyMixer before stopping the device, and instances compare it before touching a track; the CNA mixer used with the ALSA pairing is never torn down. A fire-and-forget sound's pan state is freed on a later Play(), not from the stopped callback, because the mixer may still deliver a final buffer after it.
  • Sensors. Accelerometer and Gyroscope register through a per-type subsystem and Dispose waits on a dispatch token until no other thread is inside a callback; Compass and Motion capture a generation-checked control block, never this.
  • C API. CnaCApiRuntime.cpp refuses cna_game_destroy while owned children exist or from inside a lifecycle callback, and every handle carries a generation and a creation thread that each typed lookup checks. The contract is CALLBACKS_AND_THREADING.md.

Pinned by AudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers and OpenNegotiatesAndRetainsCallbackWithoutStarting (AudioDeviceConformanceTests.cpp), Sdl2AudioDeviceTests.OpenIsPausedAndStopIsACallbackBarrier, AlsaAudioDevice.OpensTheNullDeviceAndStopIsABarrier, CnaMixer.ATrackDestroyedFromItsOwnStoppedCallbackLeavesTheMixAndIsFreedLater, FrameworkDispatcherTest.UpdateDoesNotDeadlockWhenBufferNeededDisposesTheInstance, the sensor cases AccelerometerTests.NoDispatchAfterDispose and DisposeFromWithinOwnCallbackDoesNotDeadlock (the latter exists for Accelerometer, Gyroscope, Compass and Motion), DevicesShutdownOrderingTest.*, and, in a build with CNA_BUILD_C_API=ON, the pure-C CApi_TeardownLifetime_* entries (seven modes: explicit, game-alive, game-alive-no-frame, children-alive, partial, cycles and device-alive) and CApi_HandleRegistry. Not established. The device conformance case observes callback counts for a short interval after Stop and Close: it catches a gross violation, not every race. The sensor contract explicitly leaves a callback that is already past its owner check on one thread while another thread completes destruction unsupported, and Compass::Dispose does not wait for other threads. No CNA workflow passes CNA_BUILD_C_API at this snapshot (the C API workflows run generators and gates), so the C API tests run only where a developer builds the library. The complete map is the Thread and callback map.

Claimed capabilities must be truthful

Rule. A backend must not advertise a capability and then silently do nothing when the shared layer relies on it. Unsupported means refused with a message that names the capability, or reported false, and a service is non-null exactly when its presence capability is true.

Why, and what breaks. Shared code branches on the answer. A false positive sends it down a path the backend cannot execute, and the test suite passes only on the preferred backend, because a silent no-op looks like success at the call site.

Evidence.

  • Platforms. The platform contract requires every service accessor to return null when its capability is false. The conformance suite checks the equality for gamepad, joystick, text input, sensors, haptics, input-device enumeration, clipboard, primary selection, displays, tray, camera, GL context and Vulkan surface, and that dialogs are null when neither message boxes nor native file dialogs exist. Keyboard and mouse are deliberately outside that equality because their capability fields describe quality (exact keyboard state, pixel-accurate mouse), not presence.
  • Renderers. The default IGraphicsRenderer::SupportsCapability in IGraphicsRenderer.hpp returns true for every entry except five: StencilBuffer follows SupportsStencilBuffer() (by default SupportsDepthStencil()), and MultiStreamVertexInput, CompiledEffects and the two float render-target entries default to false. Its comments say why: renderers' older capability switches often end in default: return true, so newer answers are derived from separate opt-in virtuals that default to false (SupportsCompiledEffects, SupportsComputeShadersEXT, SupportsIndirectDrawEXT, SupportsShadowSamplingEXT, SupportsImageBasedLightingEXT, SupportsTexture3DSamplingEXT, ExecutesShaderEffectSourceEXT and others). GraphicsDevice::SupportsCapability in GraphicsDevice.cpp routes CompiledEffects, both float target entries, half-float filtering, ComputeShaders and IndirectDraw to those queries, and derives MultipleRenderTargets from the renderer answer and the profile's target limit.
  • Refusal at the boundary. A multi-stream draw on a renderer that reports MultiStreamVertexInput false throws System::NotSupportedException naming the capability instead of rendering from a subset of the streams.
  • A capability can mean “accepts”. CustomEffects says a renderer accepts an effect, not that it runs the source: SOFTWARE and HEADLESS accept any source and keep their fixed path, and Vulkan takes SPIR-V. A caller that needs to know asks ExecutesShaderEffectSourceEXT(), SupportsShadowSamplingEXT(), SupportsImageBasedLightingEXT() or SupportsComputeShadersEXT() as well; asking the capability alone is how a pass reports success while drawing nothing.

Pinned by PlatformConformance.EveryServiceIsNullExactlyWhenItsCapabilityIsFalse, ServicesEveryPlatformMustHaveAreNeverNull, AnUnsupportedCapabilityRefusesNamingItself and CapabilitiesAreStableAcrossCalls; GraphicsDeviceCapabilityTest.TheMultipleRenderTargetCapabilityMatchesWhatBindingActuallyDoes, MultiSampleAntiAliasingAgreesWithWhatARenderTargetGets, SupportsCompiledEffectsOnlyOnCompletedBackends and WireFrameIsRefusedDeterministicallyOnThisRenderer; GraphicsCapabilityFloatRenderTargetTest.ASupportedFloatFormatReallyConstructsARenderTarget and EachCapabilityAgreesWithItsRepresentativeFormat; RendererCapabilityProfileTest.FormatMasksDistinguishUnknownFromUnsupported; and, in a multi-renderer binary, CrossRendererContractTest.CapabilityAnswersAreStableWithinARendererAndSurviveAReset. Not established. The refusal case in the platform suite is written for one capability (surface presentation), and nothing enforces that a newly added GraphicsCapability enumerator is overridden by every renderer: an entry that does not use an opt-in query inherits true. That is the trap to check when adding a capability (see Capability answers).

The renderer choice latches on the first successful device

Rule. The process-wide renderer selection can be changed only until the first renderer has been resolved successfully; afterwards SetPreferred, SetFallbackChain and EnableAutomaticFallback throw. Rebuilding the same renderer on a live device (a multisample change, a reset) is allowed and never re-runs resolution.

Why, and what breaks. Everything that consumes the decision, including the window kind, whether a window exists and the video subsystem, is derived once inside GraphicsDevice::resolveRenderer. A selection that could change afterwards would leave a window, a video reference and a renderer that disagree about which API is in use.

Evidence. GraphicsRendererSelection.cpp checks IsLatched() in each mutator and throws System::InvalidOperationException; resolution order is an explicit call, then the CNA_GRAPHICS_RENDERER environment variable, then the compiled default; an environment value naming a renderer that is not compiled in throws (unless a fallback chain is enabled) rather than being ignored. resolveRenderer calls the latch at the end of a successful attempt, so a failed resolution leaves the selection open, and the fallback history records why each candidate was rejected (NotCompiledIn, ProbeUnavailable, InitializationFailed, WindowKindConflict). A caller-supplied window is never recreated for a candidate of another window kind. Pinned by GraphicsRendererFallbackTest.AFailedResolutionDoesNotLatch, SubstitutionStillLatchesTheSelection, RecoveringAfterAFailedResolutionActuallyWorks and ReconstructionAfterSubstitutionKeepsTheSubstitutedRenderer (GraphicsRendererFallbackTests.cpp). Corrected. The header comment in GraphicsRendererSelection.hpp says the latch closes when the first device begins construction; the code and the tests close it at the end of successful resolution. See When exactly the latch closes.

SDL stays at its declared native edges

Rule. New production code uses CNA::Platform::IPlatform and its narrow services. SDL is referenced only inside the platform module's SDL backends, the SDL audio implementations and mixer, and the renderer families that are SDL by identity or upstream dependency; the native X11 and Wayland backends are deliberately counted, not exempted.

Why, and what breaks. If shared code calls SDL, an SDL-free CNA (native X11 or Wayland, HEADLESS renderer, NULL or ALSA audio, CNA_ENABLE_SDL=OFF) stops being a configuration that exists, and the platform abstraction becomes an argument rather than a fact.

Evidence. sdl_ratchet.py exempts modules/platform/, the renderer families sdl-renderer, sdl-gpu, fna3d and freedirect, and the SDL3 and SDL2 audio directories and the SDL3 mixer, but checks a denylist first that puts modules/platform/src/X11/, modules/platform/src/Wayland/ and the shared Xkb, Freedesktop, POSIX and Linux directories back under the rule. The budget in sdl_budget.json is zero files and zero references outside the allowlist, with a floor that --update refuses to raise. PlatformRatchet.cmake runs the check at configure time (CNA_PLATFORM_RATCHET and CNA_PLATFORM_RATCHET_STRICT default on; it is skipped without Python 3), and platform-ci.yml runs the inventory, classification, renderer audit and --strict ratchet scripts as separate steps. A per-frame companion, hot_path_lint.py, rejects platform calls inside per-pixel, per-vertex, per-fragment, per-sample or per-event loops unless annotated // CNA_PLATFORM_HOT_PATH_OK: <reason>. Pinned by ContractIsSdlFreeTests.EveryContractHeaderCompilesWithoutSdl (a translation unit that includes every platform and audio-device contract header and fails to compile if SDL comes along; check_contract.py keeps its include list complete), WaylandIsSdlFree.NoCodeInTheBackendReferencesSdlOrX11, CnaWaylandLinkClosure (no binary links SDL, X11, xcb or GLX) and CnaSdlOffFindsNoSdlPackage. Not established. The ratchet is a static scan for SDL references and the hot-path lint under-reports by design, so a pass is not proof of the runtime property; and it is a repository rule stated in CNA's instruction files as well as a gate.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.