Migrating an XNA game to CNA: a process that keeps every mismatch attributable

CNA snapshot 009d40f5  ·  Deep Dives › Porting & project practice  ·  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. Checked by reading TARGET sources at 009d40f5; not built or executed. The process is a recommended method: no CNA gate enforces it for a consumer game, and the consumer examples are evidence for their own pinned revisions.

CNA keeps much of XNA 4.0's public shape in C++, but it does not make a C# game binary- or source-compatible: a port is a controlled translation in which language, assets, renderer, services and timing each change separately. This page is the process view of that translation for the snapshot 009d40f5: what to freeze before touching code, how to inventory assets and shaders by the route CNA actually takes, how to choose a renderer by evidence, and how to build a verification ladder so that every mismatch names the boundary that caused it. The API-level substitutions (namespaces, properties, memory) are in the migration guide; worked real ports are on the porting case studies.

Classify the starting point first

Two very different projects are both called "porting to CNA". A C# XNA or FNA game needs a language translation: every class is rewritten in C++ against CNA's headers. An existing C++ game or reconstruction needs only its platform-service boundary replaced: the simulation code already compiles, and the work is to swap the layer that talks to windows, pixels, sound and files. The Speedy Blupi 2013 port did the first (decompiled C#, then C++, then CNA); the Free Eggbert feasibility study plans the second (C++ that currently runs through the DirectDraw-shaped free-direct layer). Both are described on the case-studies page.

Keep the two risks apart even when a project has both. Translating game logic and redesigning rendering in one pass destroys the ability to attribute a mismatch: when the first screenshot differs, nobody can say whether the translation, the asset conversion or the renderer is wrong. The workflow below changes one layer at a time and compares after each.

 freeze original      mechanical API      strict-XNA         asset-route
 baseline        ---> translation    ---> compile       ---> manifest
 (traces, frames,     (C# -> C++, no      (CNAEXT made        (XNB / CNB / CNJ /
  saves, assets)       redesign)           [[deprecated]])     loose / glTF)
                                                                   |
                                                                   v
 deployment      <--- behaviour +    <--- services, owner-  <--- renderer +
 matrix               pixel compare       ship, lifetime,       shader strategy
 (hosts, renderers)   vs the baseline     timing adaptation     (capability first)
                           |
                           | mismatch
                           v
                 localise it: return to the smallest step
                 (API, asset, renderer or service) that can explain it
Figure. A porting workflow that preserves attribution. The top row runs left to right: freeze the original game's behavioural and asset baseline, translate the API shape mechanically, compile against CNA's strict XNA surface, and map every asset to a concrete reader route. The bottom row runs right to left: choose the renderer and shader strategy, adapt services, ownership, lifetime and timing, then compare behaviour and pixels against the baseline before deploying across the intended host and renderer matrix. A mismatch loops back to the smallest step that can explain it rather than to a general rewrite.

Freeze a behavioural baseline

Before changing language or framework, pin the original at a named commit (or, for a shipped binary, a file hash) and record what the port must reproduce: deterministic simulation traces, representative screenshots or frame captures, save files, network packets, audio events and a complete asset inventory. Record the settings that make those captures reproducible: resolution, fixed-step interval, random seeds, locale and platform. A port without a baseline can become internally consistent while drifting away from the game it was meant to preserve.

The cna-samples campaign shows what a strict baseline looks like in practice (its plan at 4da98a0): the original project is built and run under real XNA, its commands, controls, captures and audio are retained beside the port, and a time hook (CNA_TIME in that repository) pins the instant both engines animate from so that frames can be compared pixel for pixel. CNA's own reference tools are the other half: tools/xna-oracle holds 39 declarative scenes with reference images captured from real XNA 4.0 under Wine and DXVK on Linux, and Tutorial 161 shows how a scene is diffed.

Translate the API shape mechanically

Namespaces and type and member names map directly from dots to C++ scopes (Microsoft.Xna.Framework.Graphics becomes Microsoft::Xna::Framework::Graphics). The largest systematic difference is properties: a C# read of obj.Value becomes obj.getValueProperty() and an assignment becomes obj.setValueProperty(value); content loads keep their shape as getContentProperty().Load<T>("name"), returning the asset by value. Events, nullable values (std::optional), collections, ownership and ref/out arguments (reference-parameter overloads) need explicit C++ idioms; the full rule set is on CNA's translation conventions.

Do not redesign names while translating. First reach compiling, behaviourally comparable code; only then isolate genuinely game-owned abstractions. While the translation stays mechanical, compiler errors are a useful inventory of what the game touches. A broad wrapper layer that hides every mismatch too early does the opposite: an unimplemented or deliberately refused CNA behaviour then looks like a game bug.

The compile step in the figure is literal. Every CNA member that is not XNA 4.0 API carries the CNAEXT marker, and building with CNA_STRICT_XNA_API turns that marker into [[deprecated]], so a warning names every place the translated game leans on a CNA extension (CNA's own harness for this, StrictXnaApiSurfaceCheck.cpp, is wired in Harnesses.cmake). A port that compiles cleanly in that mode uses only the XNA-shaped surface; one that does not has a named list of extensions to review.

Inventory content by the reader route it takes

CNA reads real XNB files: the container with LZX and LZ4 bodies on the typed Load<T> path (XnbDecompression.cpp; the untyped external-reference path behind ExternalReferenceReader still refuses LZ4 by name), a registered table of built-in readers (61, or 60 without native 128-bit integers), shared resources, stock effects, graphics assets, models and several audio encodings. Statements that XNB is unsupported, or that CNA cannot produce XNB, are false at this snapshot: the build-time cna-content tool writes both XNB and CNA's own CNB container (Tutorial 145).

The boundary is still narrower than XNA's pipeline, and it is route-specific rather than extension-specific:

  • The reader registry is explicit. A custom type needs a registered creator (ContentTypeReaderManager::AddTypeCreator) or a field list declared once with ReflectiveTypeReaderBuilder<T> (ReflectiveTypeReader.hpp); there is no reflective discovery. Both routes are CNA extensions (AddTypeCreator and ReflectiveTypeReaderBuilder carry the CNAEXT marker, as does a bare ContentManager()), so a game with custom content types will always show them in the strict-XNA warning list of the compile step above. Tutorial 148 walks through it.
  • The general EffectReader (EffectContentTypeReader.cpp) reads a bounded bytecode blob and hands it to Effect(GraphicsDevice&, bytes), which accepts XNA/FNA Direct3D 9 Effect Framework binaries only where the active renderer reports the compiled-effects capability; the five stock-effect readers (StockEffectContentTypeReaders.cpp) are a separate route that works everywhere a 3D pipeline exists.
  • Loose assets take other routes: CNJ descriptors, native image, audio and video formats, and direct .gltf/.glb model loading. Runtime glTF and the offline gltf_to_cnj converter share one import core but keep route-specific material, animation and grouping limits (the glTF import core).

The practical consequence is a migration manifest with one row per source asset rather than per folder. An illustrative shape (not taken from any particular game):

Source assetRoute at run timeReader or loaderSidecarsVerification oracle
player.xnb (Texture2D, LZX)XNB, first rung of the load ladderbuilt-in Texture2D readernonepixel compare against the original's first frame
glow.xnb (compiled Effect)XNB EffectEffectReader then Effect(GraphicsDevice&, bytes)nonecapability check first; then a small golden scene
level1.png with a transparent coloura Texture2D .cnj descriptor whose sourceFile names the PNGCNJ Texture2D reader, then the image loaderthe .cnj; its colorKey is one exact RGB valuebyte compare of decoded pixels, keyed texels at alpha 0
theme.midnone: no Song route accepts MIDInonenonedecide: pre-render or game-specific service

Test every concrete asset against the selected route rather than reasoning from its extension: the load ladder (.xnb, then .cnb, then loose files) means a stale sibling file silently wins, as ContentManager resolution explains, and Tutorial 147 diagnoses a foreign XNB file.

Treat custom shaders as renderer contracts

Compiled XNA/FNA Direct3D 9 Effect Framework binaries execute on 14 of the 25 renderer identities in 10 families, but only one family has the path on by default: FNA3D always, and the other nine behind default-off build options (CNA_EASYGL_COMPILED_EFFECTS, which covers the five GL identities, and the Vulkan, WebGPU, Software, DirectX 9/11/12, SDL_GPU and OpenGL 4 equivalents). A default configure therefore reports CompiledEffects true on FNA3D only. Inventory the exact byte format of every shader asset: HLSL .fx source, DXBC and MonoGame MGFX are different inputs, none accepted at run time; cna-content can compile .fx at build time through an external legacy compiler (Tutorial 150), a route whose equivalence to Microsoft's compiler CNA has not verified.

ShaderEffect is the separate source-and-program route, and the accepted language depends on the renderer: GLSL on the GL paths, HLSL on the Direct3D paths, SPIR-V bytes on Vulkan, marker-gated renderer-specific payloads on others, and a deterministic refusal on the rest (which input each renderer takes). So the order of decisions is fixed: choose the target renderer set first, then the shader strategy. A game meant to stay portable may need one program per renderer family (Tutorial 151) or may be better served by staying within the stock effects.

Audit vertex layouts and model routes

A custom XNA VertexDeclaration is not guaranteed to fit every renderer or importer route. For each vertex format record element offsets, formats, usages, stride, index width, primitive topology and where the data comes from (XNB, glTF, CNJ or buffers the game fills). The glTF path packs into a small closed set of strides, and its upload path treats that set as an ABI (vertex packing); a game-defined layout is only guaranteed on the direct-buffer route.

Models reach the runtime through five content routes (XNB, CNB, self-contained CNJ, direct glTF/GLB and the legacy skinned-model manifest), each building a slightly different graph (the route table), plus hand-built runtime objects. Skinning adds separate index spaces for joints, scene nodes and the bone palette (skinning and animation). Preserve those spaces explicitly in the port, and do not infer that a model drawing correctly on one route and one renderer proves any other combination.

Select a renderer by evidence, not by name

A build chooses from 25 public renderer identities over 21 implementation families at configure time: CNA_GRAPHICS_RENDERER names one, and CNA_GRAPHICS_RENDERERS compiles a list from which the game selects at run time (runtime selection, Tutorial 126). Filter in this order:

  1. Admissibility. Some pairs are refused at configure time (RendererSelection.cmake): OPENGLES3 is refused under Emscripten, where the same EasyGL implementation is spelled WEBGL2; the Direct3D identities build only for Windows targets; the TERMINAL platform accepts only the CPU renderers SOFTWARE, PORTABLEGL, HEADLESS and STUB.
  2. Required capabilities. 2D or 3D (seven identities are 2D-only), compiled effects, MRT, render-target and texture formats, instancing, readback and occlusion queries. The default GraphicsProfile is Reach, which refuses MRT, occlusion queries, 32-bit indices, float targets and large cubes on every renderer unless HiDef is requested (Tutorial 152). Query SupportsCapability() and the RendererCapabilityProfile report rather than a renderer's name (Tutorial 133).
  3. Evidence tier. Then read what has actually been observed for that identity on that host, which differs widely between families (what each kind of renderer can prove).

A useful progression starts on one well-verified desktop renderer, adds a structurally independent control, and only then crosses to the web or mobile. The cna-samples campaign fixed exactly such a reference pair: OPENGLES3 natively and its WEBGL2 spelling in the browser. Compile the same game against each identity; do not add renderer conditionals to game logic unless the capability contract requires them. A coloured fallback draw from an inherited default, or a test skipped on a headless renderer, is not a parity result.

Make service differences explicit

List every use of input, audio, media, storage, GamerServices, networking, sensors and platform dialogs, and write down what CNA does for each on every target host. The implementations are not equally deep:

ServiceWhat to record for the port at this snapshotDetail
AudioFour implementations (SDL3 default, SDL2, NULL, ALSA); the SOUND_ENABLED mixer exists for SDL3 and ALSA onlysound effects and streaming
MediaSong decodes Ogg Vorbis, MP3, WAV and FLAC through the mixer; Ogg Opus is not decoded (the vendored SDL3_mixer is built with SDLMIXER_OPUS=OFF and CNA's ALSA mixer refuses it by name), and there is no MIDI and no AAC route. Video types exist everywhere and throw NotSupportedException without the optional FFmpeg backend (CNA_ENABLE_VIDEO), which is never built for Windows, Emscripten, Android or iOSmedia contract
StorageThe storage root is a host substitution; in a browser, saves live in memory unless the page adds persistenceweb storage, Tutorial 141
GamerServicesNo online service; achievements and leaderboards persist locally as JSON, local identities are synthetic, the social Guide::Show* entry points are documented no-ops, and the message-box and keyboard-input dialogs are realGamerServices contract
NetworkingOnly SystemLink uses a transport (ENet); a Local session delivers no packets; browser builds find no LAN sessionsnetwork sessions
Sensors and host devicesMicrosoft::Devices::Sensors types exist; what they report depends on the hostsensors and vibration
InputSnapshots, logical mouse coordinates, touch and gestures follow XNA; text input and IME are host-dependentinput model

Timing is its own line item

CNA's Game starts with a fixed step (IsFixedTimeStep true) of 166,667 ticks, about 16.67 ms or 60 Hz (Game.cpp); the setter refuses a zero or negative span with std::out_of_range, and accumulated time is clamped at 500 ms. Set TargetElapsedTime from the original's simulation instead of accepting the default: a game whose logic counts frames runs three times too fast at 60 Hz if it was written for 20 Hz. The Speedy Blupi 2013 port sets 500,000 ticks (50 ms), and the Free Eggbert study, whose original updates from a 50 ms multimedia timer, reaches the same number independently.

Speed-up features need a decision, not a shortcut. Running several fixed simulation steps per rendered frame (for example 1, 2, 4 or 8 for a four-speed control) keeps every step identical to the original; shortening TargetElapsedTime or rescaling per-frame constants changes the simulation step itself and must be verified as a new behaviour. Decide likewise how pause, loss of focus (InactiveSleepTime), slow frames (IsRunningSlowly), variable-step mode and shutdown map on every host; the exact clock semantics are on GameTime and the timestep, and the browser loop, which does not mirror every native semantic, on the web main loop.

Build a verification ladder for the port

Use increasingly independent checks, and keep the result of each rung separate:

RungWhat it provesCNA instruments
1. Compile-time surface and negative compilationThe port uses the XNA-shaped API and nothing it should notCNA_STRICT_XNA_API; the census of the API surface (auditing the XNA API surface)
2. Deterministic logic tests with no rendererSimulation equals the original tracethe HEADLESS or STUB renderer; a fixed TargetElapsedTime
3. Asset-load tests, including malformed inputEvery route in the manifest loads, and bad bytes fail cleanlycontent input boundaries
4. Single-pixel and small golden checks on the primary rendererDraw calls produce the expected pixelsTutorial 125; module *_test.cpp programs
5. Differential screenshots or numeric traces against the frozen originalThe port behaves like the game it replacesthe baseline captures; the oracle corpus for framework-level questions
6. A second renderer as a localisation controlA mismatch is or is not renderer-specifica structurally independent identity built from the same source
7. Browser, compatibility-layer, emulator and physical-host evidenceThe claimed host actually ran the claimed code pathengagement gates

Record skips and the runtime identity with every result. A Wine result must prove that the translation layer (DXVK, vkd3d-proton or the DirectDraw path) was engaged; a browser result must reach a frame through an out-of-process verdict; a mobile result must retain its package, logs, screenshot and lifecycle trace. "Built everywhere" and "behaves everywhere" are separate milestones, and so are both from "compared with the original".

Troubleshoot from the boundary inward

Configure failures usually mean a renderer name outside the 25 public identities (a configure-time error), a missing sibling dependency (sharp-runtime must be its next branch), an inadmissible platform and renderer pair, or a feature option that changes which sources are compiled. Keep the exact configure command and the generated definitions (CNA_RENDERER_<ID>, plus CNA_MULTI_RENDERER in a multi-renderer build). Link failures usually expose module ownership (link the CNA modules whose public API the game uses, for example CNA::Runtime, CNA::Devices, CNA::GamerServices) or a missing external runtime library.

At run time, classify the first failure before editing anything:

BoundaryTypical symptomFirst evidence to collect
Asset resolutionContentLoadException naming a missing file, or the wrong sibling loadedworking directory, content root, the ladder rung that answered
Reader registrationan unregistered reader name in the XNB reader tablethe reader table of the file; the custom-type registration
Window and device creationfailure before the first Drawplatform, renderer, profile, driver
Frame lifetimestate errors, use after Dispose, SpriteBatch misusethe HEADLESS renderer's trace mode, which names the violated rule
Renderer engagementright API calls, wrong or no pixelsthe same build on a second identity
Behavioural oracleruns, but differs from the originalthe frozen baseline; XNA or FNA for the disputed call

Preserve the original exception (ContentLoadException wraps the inner error with the asset name) and reduce to the smallest asset or draw that reproduces it; investigating a sample failure gives the controls that each flip one variable. The fastest migration is not the one with the fewest red results but the one in which every red result names the boundary that failed.

Evidence and limits

Checked by reading the TARGET sources named above at 009d40f5 (timestep defaults and setter in Game.cpp, the XNB decompression and Effect readers, RendererSelection.cmake's refusals, the strict-API harness wiring); not built or executed. Counts of renderer identities, compiled-effect identities and built-in readers are the site's canonical TARGET figures. The cna-samples practices are that repository's own plan at 4da98a0 (2026-09-20); the two timing examples come from mobile-eggbert at d43c6a1 and the Free Eggbert feasibility document, and describe those projects, not CNA. The manifest table is illustrative, and the verification ladder is a method: no CNA gate enforces it for a consumer game.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Investigate a sample failure