Xenon 2

How it works · write-up

Plan: Render the DrawCommandStream using the sprite atlas (GPU debug window)

xenondoc/plan-drawcommandstream-sprite-atlas-render.md · 14 KB · updated 2026-09-17

Context

Over this session we built two halves of a pipeline that don't talk to each other yet:

  1. Capture side (C): DrawCommandStream (src/drawCommandStream.c/.h) records one DrawMaskedSpriteEntry{frame, cycle, objectId, spriteId, x, y} per masked-sprite draw call, hooked into the object list's shared drawProc dispatch. spriteId is the raw ST address of the sprite's SpriteData header.
  2. Atlas side (.NET): hatari_dotnet reads the exported sprite catalog (spritecatalog.bin, RSPR v2 format, keyed by stAddress) plus a raw ST memory dump and palette, decodes every sprite via SpriteRenderer, and packs them into one atlas image (SpriteAtlasExporter.ExportPacked, currently written only as spriteatlas_packed.png).

We confirmed (via the Explore pass below) that DrawMaskedSpriteEntry.spriteId and RenderedSpriteRecord.StAddress are literally the same key space for SPRITE_FORMAT_4PLANES_MASKED records — both are the SpriteData* ST address. That's the join key that makes this plan possible.

The goal: make the existing GPU debug-window infrastructure (sdlGpuRenderView.c, SDL3 GPU API + SDL_shadercross) render the actual sprites for the most recently completed frame, sourced entirely from the atlas texture + DrawCommandStream, as a new debug window — proof that the capture-stream + atlas pipeline is sufficient to reconstruct what's on screen, without any live CPU sprite rasterization.

Single draw call per frame, non-negotiable design constraint: every sprite in the frame is drawn with one SDL_DrawGPUPrimitives call, not one call per sprite. This means building a single CPU-side vertex array covering every sprite (6 vertices/quad, screen position + atlas UV baked into each vertex), uploading it once, and issuing one draw over the whole array. No per-sprite pipeline/texture rebinding or per-sprite submits. This is spelled out again in Phase D/E below, since it's the part of the design most likely to accidentally regress into a per-sprite loop of draw calls.

Design decision already confirmed with the user: the atlas texture will be loaded in C from a raw pixel dump (.NET writes a second, unparsed file alongside the PNG), not by decoding the PNG in C. Rationale: matches every other exchange file in this pipeline (stram.bin, palette.bin, spritecatalog.bin are all flat/unparsed formats), needs zero new parsing code, and BGRA8 byte order can be picked to match the SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM already used for the existing mainTexture. libpng is technically already linked (used for screenshot writing only) so it was a real option, just more code for no real benefit here. The PNG keeps being written too, purely for human visual inspection.

Note on an existing design comment: drawCommandStream.h's header comment currently states the stream "knows nothing about pixel formats, atlases... those are separate concerns layered on top of this." This plan deliberately breaks that stated contract by baking resolved atlas rects into each entry (per the user's explicit request, to avoid a second lookup pass at render time). Step 3 below updates that comment so it stays honest instead of going stale.


Phase A — .NET: export an atlas index + raw pixel dump alongside the PNG

Files: hatari_dotnet/SpriteAtlasExporter.cs, new hatari_dotnet/AtlasIndexWriter.cs, hatari_dotnet/Program.cs.

  1. Extend SpriteAtlasExporter.ExportPacked(...) to also accept string indexOutputPath and string rawOutputPath. Inside, before the existing WritePng call swaps the canvas to RGBA in place: - Write the raw pixel dump: tiny fixed header (magic="SATX", version=1, width, height) followed by the canvas bytes as-is (BGRA8, SpriteRenderer's native Argb8888 little-endian byte order — i.e. dump it before ConvertArgbLittleEndianToRgbaBytes runs, since that swap exists only for PNG's RGBA requirement). - Write the atlas index: same magic+version+count+records convention as RSPR (renderedSpriteExport.h) for consistency — magic="SATL", version=1, atlasWidth, atlasHeight, count, then count records {uint32 stAddress, uint16 atlasX, uint16 atlasY, uint16 width, uint16 height} (20 bytes → pack to e.g. 12 bytes; atlasX/Y already include the border offset — the exact pixel position SpriteRenderer.Render was called at — so no border math needs to be redone in C). One record per packed rectangle that resolved to a catalog entry (mirrors the existing lookup in ExportPacked's loop).
  2. Program.cs: pass spriteatlas_packed.idx / spriteatlas_packed.raw paths into the extended ExportPacked call (next to the existing spriteatlas_packed.png path). ExportCaptured is unchanged — it's for visual comparison only, not consumed by C.

Phase B — C: new spriteAtlas module (pure data, no SDL/GPU dependency)

New files: src/includes/spriteAtlas.h, src/spriteAtlas.c (added to src/CMakeLists.txt's source list next to drawCommandStream.c/renderedSpriteExport.c).

bool SpriteAtlas_LoadIndex(const char* path);   // reads spriteatlas_packed.idx into a
                                                 // sorted-by-stAddress array (qsort once)
bool SpriteAtlas_LoadPixels(const char* path);  // reads spriteatlas_packed.raw into a malloc'd
                                                 // BGRA8 buffer; validates dims against the index
bool SpriteAtlas_FindRect(uint32_t stAddress, uint16_t* outX, uint16_t* outY,
                           uint16_t* outW, uint16_t* outH);  // bsearch
const uint8_t* SpriteAtlas_GetPixels(uint32_t* outWidth, uint32_t* outHeight);

Loaded once from main.c, near the existing DrawCommandStream_Init(&g_drawCommandStream, 65536) call. Missing files are tolerated (log + continue) rather than fatal — the rest of Hatari should keep working if the .NET tool hasn't been run yet, same spirit as other optional debug features.


Phase C — C: bake resolved atlas rects into DrawCommandStream entries

File: src/includes/drawCommandStream.h, src/drawCommandStream.c.

  1. Extend DrawMaskedSpriteEntry with uint16_t atlasX, atlasY, atlasW, atlasH;.
  2. Extend DrawCommandStream_PushMaskedSprite(...)'s parameter list with the same 4 fields (it's an internal API with a single call site — no need for a second overload).
  3. DrawCommandStream_OnInstructionFetch: right after resolving spriteId (and after the existing spriteId == 0 early-out), call SpriteAtlas_FindRect(spriteId, &atlasX, &atlasY, &atlasW, &atlasH); if it returns false (sprite not in the currently loaded atlas — e.g. atlas built from a stale/different capture), return without pushing, same style as the existing early-outs.
  4. Update the header's top doc comment to reflect that entries now carry a resolved atlas rect (see the Context section's note above).

This adds a one-directional drawCommandStream.c → spriteAtlas.h include. spriteAtlas.h has no SDL/GPU includes, so this stays a lightweight dependency, not a layering violation into GPU code.


Phase D — C: GPU pipeline to draw the stream

Files: src/includes/screentrace.h (enum), src/includes/sdlGpuRenderView.h, src/sdlGpuRenderView.c, new src/shaders/sprite_atlas_fragment.glsl (+ compiled .spv), src/shaders/compilerShaders.bat.

  1. Add RENDER_VIEW_SPRITE_STREAM to the RenderViewType enum (screentrace.h).
  2. SdlGpuRenderView struct (sdlGpuRenderView.c) gains an SingleTextureData atlasTexture field, uploaded once at init (unlike mainTexture/timeTexture, the atlas is static — no per-frame re-upload), plus SDL_GPUBuffer* streamVertexBuffer + matching transfer buffer, sized for a fixed cap (SPRITE_STREAM_MAX_QUADS, e.g. 4096 — allocated once, partially filled/drawn per frame; entries beyond the cap are dropped with a one-line log, not a crash).
  3. New SdlGpuRenderView_CreateSpriteStream(SDL_Window* window, Uint32 logicalScreenWidth, Uint32 logicalScreenHeight) (mirrors SdlGpuRenderView_CreateMaskedSprites's shape) — calls the existing internal SdlGpuRenderView_Create(...) with the new type, then creates+uploads atlasTexture from SpriteAtlas_GetPixels().
  4. Shaders: - Reuse src/shaders/vertex2.glsl unchanged. NDC position and normalized UV are both precomputed on the CPU per-vertex (see Phase E) — the vertex shader only needs to pass position/UV through, which is exactly what it already does. This avoids adding a new vertex uniform / new vertex shader. - Add one new minimal fragment shader, sprite_atlas_fragment.glsl: layout(set=2, binding=0) uniform sampler2D atlasTex; → outColor = texture(atlasTex, inUV);. No uniform buffer, no highlight/progress-bar logic (that's fragment3.glsl's concern for a different window type). Compile it to .spv via glslc and add one line to compilerShaders.bat, matching the existing (manual, not CMake-wired) shader build process.
  5. CreateGraphicsPipeline: add a blend-state parameter (default disabled, matching current behavior for the 3 existing window types) so the new sprite-stream pipeline can opt into straight-alpha blending: enable_blend=true, src_color_blendfactor=SRC_ALPHA, dst_color_blendfactor=ONE_MINUS_SRC_ALPHA, alpha channel similarly — required for the masked sprites' transparent-pixel (alpha=0) regions to composite correctly, per the alpha-channel fix done earlier this session in SpriteRenderer/DrawSTMaskedTile16_ARGB8888.
  6. SdlGpuRenderView_Submit: add an RENDER_VIEW_SPRITE_STREAM branch (extends the existing type-switch, reusing the surrounding command-buffer/swapchain-acquire/submit boilerplate already there) that uploads a caller-provided Vertex array (covering every sprite in the frame, built in Phase E) into streamVertexBuffer in one shot (same transfer-buffer→copy-pass idiom already used for textures, applied to a vertex buffer via SDL_UploadToGPUBuffer), binds the pipeline + vertex buffer + atlasTexture sampler once (no uniform push needed), and issues exactly one SDL_DrawGPUPrimitives(renderPass, vertexCount, 1, 0, 0) call for the whole array — not a loop of one draw call per sprite. Requires threading a vertexCount parameter through SdlGpuRenderView_Submit's signature (unused by the other 3 window types).

Phase E — C: wire up window creation + per-frame vertex building

File: src/screentrace.c, src/main.c.

  1. ScreenTrace_Create: add a RENDER_VIEW_SPRITE_STREAM branch — no PixelMap_ReInit/ AddressMap_Reinit needed (this window doesn't trace a memory region, it reads g_drawCommandStream), just DebugWindow_InitWithSize(title, 320, 200, RENDER_VIEW_SPRITE_STREAM, windowX, windowY) → SdlGpuRenderView_CreateSpriteStream(...). 320x200 matches the existing RENDER_VIEW_LOWRES_VRAM window's logical ST screen resolution, which is the same coordinate space DrawMaskedSpriteEntry.x/y are already captured in.
  2. DebugWindow_UpdateFromSTLowResBase: add a RENDER_VIEW_SPRITE_STREAM branch: - lastCompleteFrame = DrawCommandStream_GetCurrentFrame(&g_drawCommandStream) - 1 (the header's own doc comment recommends exactly this, to avoid a still-mid-capture frame). - Walk it via DrawCommandStream_ForEachInFrame(&g_drawCommandStream, lastCompleteFrame, callback, &builderState) to build one local Vertex[SPRITE_STREAM_MAX_QUADS * 6] array covering every sprite drawn in that frame — this is the CPU-side batching step that makes the single-draw-call constraint above possible. The callback appends 6 vertices (2 triangles, no index buffer — matches the "no index buffer anywhere" convention already in this file) per entry:
    • Position → NDC: ndcX = (x / 320.0f) * 2 - 1, ndcY = 1 - (y / 200.0f) * 2 (note the Y flip: ST screen space is Y-down, NDC is Y-up).
    • UV → normalized against the atlas texture's own width/height (from SpriteAtlas_GetPixels, cached once): u = atlasX / atlasTexWidth, v = atlasY / atlasTexHeight.
    • Call SdlGpuRenderView_Submit(st, debugWindow->renderView, vertexArray, NULL, vertexCount, emulationActive) once, after the loop — not once per entry.
  3. main.c: register the new window (ScreenTrace_Create(0, false, RENDER_VIEW_SPRITE_STREAM, x, y), alongside the existing masked-sprites/lowres/memorydump registrations), and call SpriteAtlas_LoadIndex("spriteatlas_packed.idx") / SpriteAtlas_LoadPixels("spriteatlas_packed.raw") near the existing DrawCommandStream_Init call.

Verification

  1. Run Xenon 2 in Hatari far enough to populate g_drawCommandStream and trigger the existing one-shot spritecatalog.bin/stram.bin/palette.bin dump (opening the existing "Masked sprite" debug window does this today).
  2. Run hatari_dotnet against spritecatalog.bin → produces spriteatlas_packed.png (open in an image viewer as a sanity check — should look like a clean sprite sheet with transparent surrounds), plus the new spriteatlas_packed.raw/.idx.
  3. Restart Hatari so SpriteAtlas_Load* picks up the freshly generated files; open the new "Sprite Stream" debug window; confirm it shows recognizable ship/enemy/bullet shapes at plausible positions with transparent backgrounds (this also visually confirms the earlier masked-sprite alpha fix carries through end-to-end).
  4. Known things to eyeball-debug if it looks wrong on the first try: off-by-one on currentFrame - 1 (empty or stale-by-one-frame window), Y-axis flip direction (sprites mirrored vertically), UV V-axis flip (sprites sampling upside-down from the atlas).