How it works · write-up
Plan: Render the DrawCommandStream using the sprite atlas (GPU debug window)
Context
Over this session we built two halves of a pipeline that don't talk to each other yet:
- Capture side (C):
DrawCommandStream(src/drawCommandStream.c/.h) records oneDrawMaskedSpriteEntry{frame, cycle, objectId, spriteId, x, y}per masked-sprite draw call, hooked into the object list's shared drawProc dispatch.spriteIdis the raw ST address of the sprite'sSpriteDataheader. - Atlas side (.NET):
hatari_dotnetreads the exported sprite catalog (spritecatalog.bin, RSPR v2 format, keyed bystAddress) plus a raw ST memory dump and palette, decodes every sprite viaSpriteRenderer, and packs them into one atlas image (SpriteAtlasExporter.ExportPacked, currently written only asspriteatlas_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.
- Extend
SpriteAtlasExporter.ExportPacked(...)to also acceptstring indexOutputPathandstring rawOutputPath. Inside, before the existingWritePngcall 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 nativeArgb8888little-endian byte order — i.e. dump it beforeConvertArgbLittleEndianToRgbaBytesruns, since that swap exists only for PNG's RGBA requirement). - Write the atlas index: same magic+version+count+records convention asRSPR(renderedSpriteExport.h) for consistency —magic="SATL",version=1,atlasWidth,atlasHeight,count, thencountrecords{uint32 stAddress, uint16 atlasX, uint16 atlasY, uint16 width, uint16 height}(20 bytes → pack to e.g. 12 bytes;atlasX/Yalready include theborderoffset — the exact pixel positionSpriteRenderer.Renderwas 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 inExportPacked's loop). Program.cs: passspriteatlas_packed.idx/spriteatlas_packed.rawpaths into the extendedExportPackedcall (next to the existingspriteatlas_packed.pngpath).ExportCapturedis 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.
- Extend
DrawMaskedSpriteEntrywithuint16_t atlasX, atlasY, atlasW, atlasH;. - 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). DrawCommandStream_OnInstructionFetch: right after resolvingspriteId(and after the existingspriteId == 0early-out), callSpriteAtlas_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),returnwithout pushing, same style as the existing early-outs.- 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.
- Add
RENDER_VIEW_SPRITE_STREAMto theRenderViewTypeenum (screentrace.h). SdlGpuRenderViewstruct (sdlGpuRenderView.c) gains anSingleTextureData atlasTexturefield, uploaded once at init (unlikemainTexture/timeTexture, the atlas is static — no per-frame re-upload), plusSDL_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).- New
SdlGpuRenderView_CreateSpriteStream(SDL_Window* window, Uint32 logicalScreenWidth, Uint32 logicalScreenHeight)(mirrorsSdlGpuRenderView_CreateMaskedSprites's shape) — calls the existing internalSdlGpuRenderView_Create(...)with the new type, then creates+uploadsatlasTexturefromSpriteAtlas_GetPixels(). - Shaders:
- Reuse
src/shaders/vertex2.glslunchanged. 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'sfragment3.glsl's concern for a different window type). Compile it to.spvviaglslcand add one line tocompilerShaders.bat, matching the existing (manual, not CMake-wired) shader build process. 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 inSpriteRenderer/DrawSTMaskedTile16_ARGB8888.SdlGpuRenderView_Submit: add anRENDER_VIEW_SPRITE_STREAMbranch (extends the existing type-switch, reusing the surrounding command-buffer/swapchain-acquire/submit boilerplate already there) that uploads a caller-providedVertexarray (covering every sprite in the frame, built in Phase E) intostreamVertexBufferin one shot (same transfer-buffer→copy-pass idiom already used for textures, applied to a vertex buffer viaSDL_UploadToGPUBuffer), binds the pipeline + vertex buffer +atlasTexturesampler once (no uniform push needed), and issues exactly oneSDL_DrawGPUPrimitives(renderPass, vertexCount, 1, 0, 0)call for the whole array — not a loop of one draw call per sprite. Requires threading avertexCountparameter throughSdlGpuRenderView_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.
ScreenTrace_Create: add aRENDER_VIEW_SPRITE_STREAMbranch — noPixelMap_ReInit/AddressMap_Reinitneeded (this window doesn't trace a memory region, it readsg_drawCommandStream), justDebugWindow_InitWithSize(title, 320, 200, RENDER_VIEW_SPRITE_STREAM, windowX, windowY)→SdlGpuRenderView_CreateSpriteStream(...). 320x200 matches the existingRENDER_VIEW_LOWRES_VRAMwindow's logical ST screen resolution, which is the same coordinate spaceDrawMaskedSpriteEntry.x/yare already captured in.DebugWindow_UpdateFromSTLowResBase: add aRENDER_VIEW_SPRITE_STREAMbranch: -lastCompleteFrame = DrawCommandStream_GetCurrentFrame(&g_drawCommandStream) - 1(the header's own doc comment recommends exactly this, to avoid a still-mid-capture frame). - Walk it viaDrawCommandStream_ForEachInFrame(&g_drawCommandStream, lastCompleteFrame, callback, &builderState)to build one localVertex[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.
- Position → NDC:
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 callSpriteAtlas_LoadIndex("spriteatlas_packed.idx")/SpriteAtlas_LoadPixels("spriteatlas_packed.raw")near the existingDrawCommandStream_Initcall.
Verification
- Run Xenon 2 in Hatari far enough to populate
g_drawCommandStreamand trigger the existing one-shotspritecatalog.bin/stram.bin/palette.bindump (opening the existing "Masked sprite" debug window does this today). - Run
hatari_dotnetagainstspritecatalog.bin→ producesspriteatlas_packed.png(open in an image viewer as a sanity check — should look like a clean sprite sheet with transparent surrounds), plus the newspriteatlas_packed.raw/.idx. - 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). - 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).