I need to fix a renderer bug

CNA snapshot 009d40f5  ·  Development › Maintainer Handbook  ·  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. Commands, targets, tests, environment variables and symbols were read from CMake, the launchers and the sources at 009d40f5; nothing was configured, built or run, and no GPU, driver or display-server result is claimed.

A renderer bug is hard to place because CNA answers "which renderer?" in several unrelated ways: the identity CMake made the default, the set that was compiled in, the identity the runtime latched, the implementation family behind it, and the host (platform, driver, display server) it ran on. This recipe is the routine for pinning a symptom to one of those axes, reproducing it with the smallest registered test, deciding whether the fault is in shared neutral code or in one translation layer, and proving the fix on a second, genuinely independent backend. Every command, target, test and symbol was read from the CNA source at snapshot 009d40f5; nothing was configured, built or run for this page.

ℹ

The mechanics are linked, not repeated: Renderer selection, GraphicsDevice, the indexed draw trace, Graphics backends, the generated Selection axes index and the evidence matrix in What to test after changing X.

Find the owner: identity, family, host

Before opening a renderer file, write down the answer to each row; "it is broken on OpenGL" has not yet named an owner.

AxisWhere it is decidedHow to read the answer
Identity at configure time (default and compiled set)CNA_GRAPHICS_RENDERER (the default), the optional CNA_GRAPHICS_RENDERERS list and the CNA_RENDERER_<X> switches in cmake/RendererSelection.cmake; names outside the 25 are refused by cmake/RendererIdentities.cmake.The configure log lines CNA: renderer set -- <list> (default: <X>) and CNA: renderer registry -- <n> family/families, the build tree's CMakeCache.txt and generated/CnaRendererRegistry.generated.cpp.
Identity at run time (what the first device got)CNA::GraphicsRendererSelection in modules/core/src/GraphicsRendererSelection.cpp: an explicit SetPreferred, else the CNA_GRAPHICS_RENDERER environment variable, else the compiled default, plus an optional fallback chain; resolved by GraphicsDevice::resolveRenderer in modules/graphics/src/Xna/GraphicsDevice.cpp.GetSelected() (never latches), GetActive() (throws before a device exists), IsLatched(), GetFallbackHistory(); on a device GetGraphicsRendererType() and GetGraphicsRendererName(); the startup line CNA: graphics renderer: <NAME> (through the logger to stderr, with (selected at runtime from N compiled in) appended in a multi-renderer build). CNA::getCurrentGraphicsRendererType() is only the compile-time default and can disagree with a multi-renderer device.
Family (which directory implements the identity)The identity-to-family map in cmake/RendererRegistry.cmake. Five GL identities (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2) are one EasyGL implementation with a run-time profile; OPENGL4 is a separate family; GDI recompiles eight Software translation units with CNA_SOFTWARE_2D_ONLY.The generated renderer identity table; the family's *RendererDescriptor.cpp under modules/renderers.
Host (platform, GPU, driver, display server)CNA_PLATFORM and the family's descriptor (windowKind, needsGlContext, needsVulkanSurface, needsSurfacePresenter); the machine itself.The family's startup log (Vulkan: [Vulkan] GPU: <device>; OpenGL4: OpenGL4Renderer initialized with OpenGL <version> on <GL_RENDERER>; EasyGL: a CNA: EasyGL capabilities line), GetRendererCapabilityProfileEXT().GetEnglishReport(), and the display server (Xvfb, a private Xwayland with DRI3, Wine, a real desktop).

There is no GetDriverName-style accessor at this snapshot. GraphicsAdapter::getDescriptionProperty() is a display name and getDeviceNameProperty() a synthetic \\.\DISPLAY<n> path, so neither identifies a driver; the adapter does carry PCI vendor and device ids. Keep the family's own log lines with the bug report as the driver evidence.

The classification that saves the most time: a bug that reproduces on every family belongs in neutral code (GraphicsDevice validation, state or offset construction, a shared helper); a bug on one family belongs to that family's declaration interpretation, program mapping, submission or resource lifetime. A bug on exactly EasyGL and OpenGL4 may live in the two headers they share, GlStockShaderSources.hpp and GlPresentationSurfaceState.hpp (OPENGL4 and EasyGL).

Symptom to suspect

SymptomFirst suspectRead firstStart with
Wrong pixels on every rendererNeutral state or offset construction: GpuDrawParams, element-versus-byte arithmetic, FoldedVertexStreamOffset(), validateDrawState().Draw trace: the neutral public methodThe neutral fixtures IndexedDrawDeferredTests, OrdinaryDrawBindingOffsetTests and OrdinaryDrawMultiStreamTests in the graphics tests, then a Software_* program.
Wrong pixels on one familyThat family's declaration or program mapping, sampler or format translation, or present transform (a letterbox or overscan mismatch is a viewport bug, not a shader bug).The family page from the family mapThe family's own named example (Software_IndexedAddressing, Vulkan_DeferredResourceLifetime, EasyGL_RenderTargetPreserveAcrossResize) and a shared *_Parity_* fixture.
Crash or black frame after resizeThe logical-versus-drawable split: UpdateViewportFromWindow, the renderer's OnSurfaceChanged, then swapchain or framebuffer rebuild. A zero-sized minimised surface must not present.Present, viewport and the backbuffer, then Vulkan swapchain policy or EasyGL presentationVulkan_SwapchainOutOfDate, Vulkan_SwapchainChurn, SdlGpu_SwapchainRecovery, SdlGpu_MinimizedRetry, Software_BackbufferResize.
Leak or crash at shutdownTeardown order: the device drains its resource registry, then renderer, presenter, window and video reference; resources that can outlive a family must be disconnected from it.Resource registry versus C++ ownership, Ownership and shutdown, Debug shutdown and lifetimeVulkan_DeviceDisposeOrder, Vulkan_ResourceLeak, Software_ResourceLeak, EasyGL_BoundResourceDispose; Headless_ResourceRenderers for a registry-level leak assertion.
Device or context lostNot one protocol. Six families raise the device events (DirectX 9, 11, 12, Direct2D, WebGPU; Vulkan reports loss once, then throws); the GL families recover per context generation; the rest never call the callback.Device loss note, EasyGL context lossVulkan_DeviceLostContract, EasyGL_Gltf_ContextLoss; the F9 and F10 keys call the renderer's debug context-loss seam from Game::PollEvents.
Shader or program compile failureDecide which of four routes failed: the shared stock GLSL, a custom ShaderEffect, a compiled (MojoShader) effect, or SPIR-V intake. Each takes a different branch of DrawIndexedPrimitivesEx.EasyGL stock corpus, SDL_gpu shader routesEasyGL prints Vertex shader compilation failed: style lines to stderr; OpenGL4 reports a failed stock program on its [OpenGL4 GL Error] line; a Vulkan payload problem shows in tools/vulkan/validate_spirv_payloads.py and the SpirvPayloadValidation test.
Throw naming Reach or HiDefUsually correct behaviour. The default profile is Reach, enforced in neutral code as System::NotSupportedException (GraphicsDevice.cpp, IndexBuffer.cpp, OcclusionQuery.cpp, the texture classes): draws above 65,535 primitives, 32-bit user indices, separate alpha blend, non-power-of-two wrap addressing and GetBackBufferData all refuse.Reach and HiDef profile limitsGraphicsProfileBlendStateTests, GraphicsProfileDrawLimitTests, GraphicsProfileDrawStateFormatTests, GraphicsProfileResourceCeilingTests. A test that died on such a refusal before its assertions is a test defect: tools/platform/profile_dead_tests.py names them.
Capability answer wrongA renderer's SupportsCapability arm that defaults to true, a family virtual left at its false default, or a device-level derivation (seven members are answered at device level: CompiledEffects, HalfFloatTextureLinearFiltering, ComputeShaders, IndirectDraw, both float render-target entries, MultipleRenderTargets).Capability answersGraphicsDeviceCapabilityTests, RendererCapabilityProfileTests, GraphicsCapabilityFloatRenderTargetTests; Vulkan is compiled with -Werror=switch so a new enumerator breaks its build until answered.

Read first

  1. IGraphicsRenderer.hpp: the contract and every default. A default that means "unsupported" is a decision; a default that means "yes" is a candidate bug.
  2. The affected path in GraphicsDevice.cpp (DrawIndexedPrimitives, SetRenderTargets, Present, Reset, Dispose), so a neutral cause is excluded first.
  3. The family's *RendererDescriptor.cpp, then the renderer method named by the symptom.
  4. The family's examples/CMakeLists.txt and TestHelpers.cmake: which named tests exist, under what guard, and which output gates and skip convention decide what a "pass" means.

Reproduce narrowly

Pick the smallest configuration that can show the bug, and record its axes with the result: CNA_PLATFORM, CNA_AUDIO_PLATFORM, the default renderer, the compiled set, the build type and the host.

# a family whose tests register when it is the DEFAULT identity: configure a tree for it
cmake -S . -B cmake-build-vulkan -G Ninja -DCMAKE_BUILD_TYPE=Debug \
      -DCNA_GRAPHICS_RENDERER=VULKAN -DCNA_BUILD_TESTS=ON -DCNA_BUILD_EXAMPLES=ON
cmake --build cmake-build-vulkan --target cna_test_vulkan_swapchain_outofdate
ctest --test-dir cmake-build-vulkan -N -R '^Vulkan_Swapchain'     # what is registered here
tools/platform/run_gpu_tests_private.sh cmake-build-vulkan -R '^Vulkan_SwapchainOutOfDate$' --output-on-failure

# a neutral-layer bug: the unit preset compiles the STUB renderer, so most renderer-gated cases SKIP
cmake --preset unit
cmake --build --preset unit-graphics
./cmake-build-unit/CnaGraphicsTests --gtest_filter='GraphicsProfile*'   # run from the repository root

Three facts decide whether that reproduction means anything.

  • Which renderer the executable created. The EasyGL_* and Headless_* example blocks compare against the build's default identity, so they register only for a default. The Vulkan_*, Software_*, SdlGpu_*, OpenGL4_* and Stub_Smoke blocks are guarded by CNA_GRAPHICS_RENDERER STREQUAL their own identity; the renderers directory re-points that variable while it enters each family, so the block is also entered for a non-default member of a multi-renderer set, and the executables then run under the default renderer. In such a build confirm with the startup line which renderer a test really created before citing its result.
  • Whether the test ran or skipped. Exit code 77 is a skip (a missing display, adapter or helper), not a pass. Read ctest -N and the skip count. Some examples link SDL3 directly and are absent without it (Stub_Smoke links it unconditionally; the Software and EasyGL example blocks return early without an SDL3 target).
  • Which display it ran on. CNA_TEST_DISPLAY is empty by default, so a window test inherits the caller's DISPLAY. Use tools/platform/run_gpu_tests_private.sh for GPU work: it starts a private headless Weston and a rootful Xwayland with DRI3, exits 77 when either is missing, refuses a build tree whose cache forces CNA_TEST_DISPLAY, and warns when the private display has no DRI3. Xvfb cannot present Vulkan.

For a selection question rather than a pixel one, the environment is enough. In the multi-renderer preset (HEADLESS;SOFTWARE;STUB, default HEADLESS) CNA_GRAPHICS_RENDERER=SOFTWARE chooses at run time, CNA_DEBUG_UNAVAILABLE_RENDERERS makes a probe fail, CNA_DEBUG_FAIL_RENDERER_INIT makes construction fail and CNA_FORCE_HEADLESS_DEVICE_EXT forces the windowless device mode (device flags).

Pick a second, independent backend

A fix is not proven by the backend it was written against. Choose the second backend by asking which code the suspect shares with it: two backends are independent for a claim only if neither contains the code under suspicion.

Second backendWhat it can separateLimits and traps
SOFTWARE (internals); also GraphicsAdapter::setUseReferenceDeviceProperty(true)A neutral-layer error from a native translation error: it rasterises on the CPU inside the draw call, so pixels are readable on any host and there is no queue, fence or device-loss model.Stock CPU shading only (CustomEffects is false). Its tests link SDL3. A pass is CPU evidence, not hardware parity. GDI shares its sources.
HEADLESS (internals); also setUseNullDeviceProperty(true)State, argument and lifetime errors: Validation mode throws a named exception, the resource registry offers AssertNoLeaks(), Trace mode records a call log for drift comparison.No pixels, and readback is refused on purpose. A shared pixel fixture may pass by asserting the refusal. Do not "fix" a pixel fixture by inventing readback values here.
STUB (internals)Only that the neutral route is reachable without a window.Draws nothing, keeps no bookkeeping, reports every capability false. It is the renderer of the unit preset, so a green unit run is not renderer evidence.
EasyGL profile (OPENGLES3, OPENGL33)An immediate-context GL result against an explicit-GPU one; profile differences (ES 1.00 GLSL, base-vertex rebase, MRT limits).Shares GlStockShaderSources.hpp and GlPresentationSurfaceState.hpp with OPENGL4: agreement between those two does not independently confirm either shared piece.
OPENGL4 (internals)A desktop 4.1+ core context with its own loader and window contract; strict [OpenGL4 GL Error] output gate.Not a sixth EasyGL profile. The 346 EasyGL programs rebuilt against it (OpenGL4_EasyGLParity_*) compare it to EasyGL; they are not a second opinion on the shared headers.
VULKAN (internals)A deferred, explicit-GPU replay: catches state read at record time instead of captured at enqueue time, and lifetime bugs behind the fence.Needs a Vulkan device and a private display with DRI3; the validation output gate fails any registered test that prints [Vulkan Validation].
SDL_GPU (internals)A queued-draw, pass-segment design over Vulkan, Direct3D 12 or Metal chosen by SDL.Requires the SDL3 platform. One identity over several native APIs: a Vulkan result does not establish Direct3D 12 or Metal.

Two ready-made comparison layers exist. The 32 renderer-neutral parity fixtures in ParityFixtures.cmake are registered by EasyGL, WebGPU, SDL_GPU and OpenGL4 as <Prefix>_Parity_<fixture> (label Parity); each states its own expected result, so the oracle is the fixture's assertions, not real XNA. scripts/run-parity-fixture.sh also diffs one fixture's raw frame between an EasyGL and a WebGPU build with cna_diag_compare (default tolerance 2); the renderer is a compile-time choice, so two renderers mean two build trees. For "does this match real XNA" use the oracle corpus (tools/xna-oracle/README.md, scripts/run-oracle-corpus-diff.sh, scripts/xna-diff.py) and read Using the XNA oracle as evidence first: the references were captured under Wine and DXVK, and CNA's records show a whole-corpus zero-diff only for DIRECTX9.

Harnesses, output gates and diagnostics

Every renderer example is registered through cna_register_renderer_test in cmake/TestHelpers.cmake; its parameters (ENVIRONMENT, WORKING_DIRECTORY, TIMEOUT, LABELS, SKIP_REGULAR_EXPRESSION) are part of the test's meaning (the Vulkan examples set SDL_VIDEODRIVER=x11 and DISPLAY=${CNA_TEST_DISPLAY}, and the working directory defaults to the top build directory). The output gates that turn diagnostics into failures are attached by that helper and by gtest_discover_tests in UnitTests.cmake:

FamilyDiagnostic hookWhat fails a test
VulkanKhronos validation layer in non-NDEBUG builds when installed; CNA_VULKAN_LIFETIME_TRACE=1 (one line per ownership transition); CNA_VULKAN_SAMPLER_TRACE and CNA_VULKAN_TARGET_READBACK_TRACE as narrower probes.Any output containing [Vulkan Validation], in any configuration that compiles VULKAN; the per-test exemption list is empty.
OpenGL4A synchronous KHR_debug callback in Debug builds or with CNA_OPENGL4_DEBUG_OUTPUT=1.Any [OpenGL4 GL Error] line; also empty exemptions.
SDL_GPUSDL's debug mode.Output matching Validation (Error|Warning), VUID- or a Direct3D 12 ERROR|WARNING line, for every test in its directory.
EasyGLglGetError checks at specific points; no debug-message callback.Only each test's own assertions.
HeadlessCNA_HEADLESS_MODE=Fast|Validation|Trace (unset means Validation).A HeadlessValidationException in the validating modes.

A message raised while the device is destroyed is out of reach of any in-process assertion, which is why the gates read the process output. Direct3D 11 and 12 have their own debug-layer switches (CNA_D3D11_DEBUG_LAYER, CNA_D3D12_DEBUG_LAYER, CNA_D3D12_GPU_VALIDATION), which need a Windows target.

Sanitizers

Configure the sanitizer in the tree that reproduces the bug instead of assuming a preset covers it. CNA_SANITIZE takes a comma-separated list (for example address,undefined) and CNA_SANITIZE_OPTIMIZATION takes DEFAULT, O0 to O3 (BuildPerformance.cmake); address with thread, and thread with memory, are refused, as are MSVC, non-GNU/Clang compilers, Emscripten and CNA_ENABLE_IPO. The configure line CNA: sanitizers enabled: ... confirms the flag reached the build. The devices-asan, devices-tsan and devices-ubsan presets in CMakePresets.json (Debug, OPENGLES3, CNA_DEVICES=ON, target CnaTests) exist for device work and are not a renderer sanitizer matrix.

For a lifetime bug, reproduce both the explicit Dispose() and the destructor or device-outliving path. Driver-side leaks are recorded, not silenced: lsan_x11_mesa.supp lists only leaks reproduced without any CNA code (including Mesa state that only eglTerminate frees) and deliberately leaves the RADV Vulkan driver's allocation alone; use it through LSAN_OPTIONS=suppressions=tools/platform/lsan_x11_mesa.supp.

Make the smallest change

Put the fix where the wrong decision was made, and keep the invariants that the source tours record.

  • Validation belongs in neutral code, translation belongs in the family. A precondition every renderer must hold goes in GraphicsDevice; layout, resolve and attachment construction stay in the backend. Compare one CPU-staging family and one native family, because RequiresManagedBufferedDrawRangeValidationEXT() splits them (EasyGL, OpenGL4 and Software answer false; every other family, Vulkan included, keeps the device's range guard).
  • Units. startIndex is an index-element offset; baseVertex and each stream's VertexOffset are vertex-element offsets, converted to bytes with that stream's own stride. A byte-offset fix in the neutral layer is almost always the wrong unit.
  • Deferred families capture at enqueue time. A field that affects replay must be captured when the draw is queued (Vulkan's Pending3DDraw, SDL_gpu's queued commands); reading it only at record time gives earlier draws the last draw's state.
  • Capabilities are promises. Answer SupportsCapability without a permissive default: return true, return false from EXT hooks you do not implement, and refuse 3D work through Ensure3DSupported or HandleUnsupported3DCall. Do not advertise a feature whose path throws or silently draws nothing.
  • Teardown order is contract. A resource class that can outlive its renderer needs a place in the destructor's ordered walk, a release hook and an owner disconnection (Vulkan's live lists, SDL_gpu's ReleaseStorageBuffersForRendererTeardownEXT, EasyGL's member order with the platform context declared first so it dies last).
  • The historical pattern. Software once ignored startIndex and baseVertex while the GPU families were correct; it is fixed and pinned by Software_IndexedAddressing. When one family disagrees with the rest, expect the neutral layer to be right and one translation to have ignored a field.

Prove it

  1. A test that fails before the change, at the narrowest level that can fail: a neutral fixture for a shared rule, the family's named example for a translation bug, a parity fixture when the behaviour is renderer-neutral. Add the second frame, the resize or the disposal step; a one-frame screenshot does not expose stale deferred state or resource retirement.
  2. The family's tests and its output gate, on a host that has the API, through the private display wrapper.
  3. The neutral graphics suite (cmake --build --preset unit-graphics; read its skip count, since the unit preset compiles STUB).
  4. A second independent backend from the table above, running the same source.
  5. A sanitizer run for anything that changed ownership or teardown.

Write the result the way the evidence supports it: commit, platform, audio and renderer axes, which named tests passed and which were skipped and why, the host and display server, and what was not run. A build is not a test pass, a registered test is not a passing test, a skip is not evidence, and a Wine or software-driver pass is not a native-GPU pass. What STUB, HEADLESS, SOFTWARE and a native renderer can and cannot establish is tabulated in Choose the graphics oracle by the failure you could have caused.

Check the blast radius

If the change touchedAlso check
GraphicsDevice or a shared headerAll 21 families still answer the new default correctly; CPU, GL and explicit-GPU families compile; the C ABI and bindings if a public enum or header moved (Update the C API).
IGraphicsRenderer contractAn explicit decision in every family, STUB included; the descriptor gate keeps unselected families compiling.
A GL shared headerBoth EasyGL (per-profile adaptation) and OpenGL4; the profile matrix, not one profile.
Descriptor, registry or selection CMakeChange build configuration; single- and multi-renderer configures; scripts/check_renderer_*.py; the combination rules (PORTABLEGL cannot join any real-GL identity, GDI cannot join SOFTWARE, one platform partition per binary).
Effects or shadersWhich of the four shader routes changed; compiled effects are default-off on nine families, so a default configure exercises only FNA3D's.
Presentation or viewportFamilies overriding GetDefaultViewportRect(); letterbox and overscan; a second frame after a resize.

The broader classification of how far each kind of change reaches is on Blast radius and readiness.

Review checklist

  • Does the description name the identity, family and host, and quote the family's startup line?
  • Does a test fail without the change, for the right reason and not on a Reach refusal or a skipped branch?
  • Was a second backend that does not share the suspect code run, or is its absence stated?
  • Are skips, gates and displays reported (private Weston plus Xwayland, Xvfb, Wine, real GPU), with the count of skipped tests?
  • Does every family that inherits the changed default still answer truthfully?
  • Was state mutated before validation could throw, and does a deferred family capture at enqueue time?
  • Is any new native resource in the destructor's ordered walk and disconnected from a device it outlives?
  • If a public signature, enum ordinal or capability member moved, was the C ABI baseline considered?

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