Xenon 2

How it works · write-up

Plan: Capture & render the starfield on GPU

xenondoc/plan-starfield-gpu-rendering.md · 11 KB · updated 2026-09-17

Context

You asked to investigate how the starfield is drawn and plan GPU rendering for it. This plan supersedes two earlier drafts of mine that got the mechanism wrong (a "write-observation replay" approach you explicitly rejected in favor of genuinely understanding the algorithm, after my maybe_background_starts_FUN_00006f36 misreads). You've since done the real reverse-engineering yourself — decompiled and precisely documented Starfield_UpdateAndDraw_48Stars (0x2b1a), Starfield_UpdateVerticalPositions (0x2aba), Starfield_InitializeRandomPositions (0x7f0a), and the Starfield_48StarRecords state array (0x3d1dc), and checked renamed symbols + plate comments into the Ghidra project. I cross-checked your findings directly against the raw decompilation and your own Ghidra plate comments (via get-comments) — everything lines up exactly, including the 4-groups-of-12 plane-write pattern and the "only plot where all 4 destination planes are zero" occlusion check. This plan is grounded in that confirmed mechanism, not a guess.

Confirmed mechanism: - Called at 0x7a86, right after the wall/background pass (0x7a82) and before the object/sprite pass (0x7a9c) — z-order is background → stars → sprites, confirmed. - 48 stars, state array at 0x3d1dc, 48 × 6-byte records: {screenByteOffset:u16, verticalSubpixelPhase:u16, xBitMask:u16}. - Every frame, Starfield_UpdateVerticalPositions (0x2aba) advances each star's position first (reads signed scroll speed from 0xcd8; star i's speed = scrollSpeed * (1 + i/48) — a continuous per-star gradient, not discrete tiers), then Starfield_UpdateAndDraw_48Stars plots all 48 from the now-current record values. Positions are recomputed and the whole field is redrawn every frame — no persistent/sticky state to model on our side; we just read current values each frame, like every other capture in this project. - 4 fixed groups of 12 stars (by array index, not by any stored field): stars 0–11 write only bitplane 2 (color index 4); 12–23 write planes 0+2 (index 5); 24–35 write planes 1+2 (index 6); 36–47 write planes 0+1+2 (index 7). Confirmed directly against the group loops' OR patterns in the decompilation. This is the same colorIndex = 4 | planeBits convention already used for bullets (src/xenonRender.c:3163) — plane 2 always on, indices 4–7 selected by which of planes 0/1 also get set. - Each plot is gated: only writes if the addressed pixel is currently black in all 4 planes (never overwrites a wall/background pixel; the object pass draws over stars afterward — matches the confirmed z-order). - Position wraps every 0x7800 bytes = exactly 192 scanlines — independently corroborates the 192-line playfield height already established for the wall-tile/background work this session.

Design

Capture: direct state-array read, not instruction-level replay

Unlike every other format captured so far, the star positions already live in a small, resident, fully-decoded state array — there's no need to decode addressing math from instruction operands. Add one new hook, fired once per frame after Starfield_UpdateVerticalPositions returns (i.e. at the PC immediately following the bsr.b 0x2aba inside Starfield_UpdateAndDraw_48Stars — pin the exact address from disassembly during implementation; hooking at 0x2b1a itself would read stale positions since the update call hasn't run yet), analogous to the existing instruction-fetch hook pattern (DrawCommandStream_OnBackgroundDrawInstructionFetch is the closest precedent — a single per-frame hook, not per-instruction).

New function in drawCommandStream.c, e.g. DrawCommandStream_OnStarfieldDrawInstructionFetch(addr): - Gate on the pinned PC. - Loop i = 0..47: read the 6-byte record at 0x3d1dc + i*6 via STMemory_ReadWord. - y = byteOffset / 160 (same divisor as the existing tile decode at drawCommandStream.c:444). - wordSlot = (byteOffset % 160) / 8; xWordBase = wordSlot * 16 (same shape as the existing tile x decode at drawCommandStream.c:443, but at 8-bytes-per-word-group granularity since only one bit within the 16-bit mask is the actual lit pixel, vs. tiles which fill the whole 16px word). - Find the single set bit in xBitMask using the project's established "bit 15 = leftmost pixel" convention (documented at xenonRender.c:3068-3070, used at xenonRender.c:1046 etc.): bitOffset where xBitMask & (0x8000u >> bitOffset) is set; x = xWordBase + bitOffset. - colorGroup = i / 12 (0–3, matching the 4 fixed groups above; color index = 4 + colorGroup). - Occlusion check (replicates the game's own gate exactly — see below): skip this star entirely if occluded. - Push one lightweight entry per star via a new dedicated push function (see below), only for stars that pass the occlusion check.

Occlusion check: read the live framebuffer instead of accepting a known gap

The real game only plots a star when its destination pixel is black in all 4 planes this frame. Since our capture hook runs inside the live emulation, at the exact point after Starfield_UpdateVerticalPositions returns and before the game's own draw loop does this same check, the real ST framebuffer at that moment already contains this frame's fully-drawn wall/background content (that pass ran earlier in the same frame, at 0x7a82). So we can replicate the game's own decision exactly, rather than approximate it or accept a gap:

  • Framebuffer base: drawBufferBase = STMemory_ReadLong(DRAW_BUFFER_BASE_ADDR) — the same read already used for the tile decode (drawCommandStream.c:441).
  • Standard ST interleaved-bitplane layout (already relied on elsewhere in this codebase): for a given 16-pixel word-group, planes 0–3 are 4 consecutive words at +0/+2/+4/+6 bytes. wordAddr = drawBufferBase + byteOffset (the record's stored screenByteOffset already points at this word-group, per the confirmed record layout above).
  • planesOR = ReadWord(wordAddr+0) | ReadWord(wordAddr+2) | ReadWord(wordAddr+4) | ReadWord(wordAddr+6).
  • If (planesOR & xBitMask) != 0, the destination pixel is already non-black this frame — skip pushing this star (matches the game's own gate). Otherwise push it.
  • Pin the exact plane-word offsets and record-field order against the 0x2b1a decompilation during implementation to be certain (the record field order — byteOffset, verticalSubpixelPhase, xBitMask — is already confirmed via your Ghidra plate comment on 0x2aba).

This makes the reconstruction match the original exactly for this case, rather than leaving a known gap: no star can ever render over wall/background content our own capture already drew this frame, because we're reading the same memory the original game reads, at the same point in the same frame, before it writes.

New push path: DrawCommandStream_PushStarPixel

Stars need no atlas lookup at all (flat single-pixel color, no texture pattern), so reusing DrawCommandStream_PushMaskedSprite (already a 12-parameter atlas-rect-based call) would mean passing meaningless atlas fields. Add a small dedicated function instead:

void DrawCommandStream_PushStarPixel(uint32_t frame, uint64_t cycle,
  int16_t x, int16_t y, uint8_t colorGroup /* 0-3 */);

Internally builds a DrawMaskedSpriteEntry with objectId/spriteId set to a sentinel (e.g. the star's state-array address, 0x3d1dc + i*6, for traceability in the sprite-stream log), atlasX/Y unused, atlasW/atlasH set to the on-screen quad size (1 or 2px — see rendering below), and two new fields:

  • bool isStarField;
  • uint8_t starColorGroup; /* 0-3, selects palette index 4+group */

added to DrawMaskedSpriteEntry in drawCommandStream.h, alongside the existing isFlash / isBackgroundWrap bools (same style).

Rendering: extend the flash-tint mechanism from 1 fixed color to 4

The existing flash mechanism (isFlash → per-vertex inFlash 0/1 → shader mixes sampled texColor with a single uniform flashColor, itself recomputed every frame from live STRGBPalette[7] at sdlGpuRenderView.c:1936-1940) is structurally exactly what stars need, generalized from 1 forced color to a per-vertex choice of 4, with texture sampling skipped entirely (no atlas rect backs a star).

  • SpriteStreamVertex gains one more float: starColorGroup (-1.0 = not a star / normal entry, else 0.0–3.0). New vertex attribute, location 4, in CreateGraphicsPipeline (sdlGpuRenderView.c, alongside the existing inBgWrap addition from the background work).
  • New uniform vec4 starColors[4] in UniformBufferSpriteStream, computed once per frame in SdlGpuRenderView_Submit via the same per-channel math as flashColor (sdlGpuRenderView.c:1935-1940), for STRGBPalette[4]..STRGBPalette[7].
  • Fragment shader (sprite_atlas_fragment.glsl): if inStarColorGroup >= 0.0, output starColors[int(inStarColorGroup)] directly (skip the texture() sample and the flash/bgwrap mix entirely); otherwise fall through to existing logic unchanged.
  • Vertex shader (vertex_sprite_stream.glsl): passthrough of the new attribute, same shape as the existing inFlash/inBgWrap passthroughs.
  • screentrace.c's SpriteStreamVertexBuilder_AppendEntry: when entry->isStarField, emit the quad's 6 vertices with starColorGroup = entry->starColorGroup and skip the atlas-rect/UV logic (UV can be zero — never sampled). Quad size: start with 2×2 screen pixels for visibility (a true 1×1 quad is a defensible alternative if 2×2 looks too chunky at your display scale — worth eyeballing both once it's on screen).

Files to change

  • src/drawCommandStream.c — new DrawCommandStream_OnStarfieldDrawInstructionFetch, new DrawCommandStream_PushStarPixel.
  • src/includes/drawCommandStream.h — DrawMaskedSpriteEntry.isStarField / .starColorGroup, new function declarations.
  • src/screentrace.c — hook wiring (call the new instruction-fetch handler from ScreenTrace_LogRead, same as the background hook), SpriteStreamVertexBuilder_AppendEntry star-quad path.
  • src/sdlGpuRenderView.c — SpriteStreamVertex.starColorGroup field, new vertex attribute (location 4, num_vertex_attributes bump), UniformBufferSpriteStream.starColors[4], per-frame palette conversion for indices 4–7.
  • src/shaders/vertex_sprite_stream.glsl, src/shaders/sprite_atlas_fragment.glsl — new attribute/uniform plumbing and the skip-sampling branch.

Verification

  1. Build, open the "Sprite Stream" debug window during gameplay with active scrolling.
  2. Confirm: a scattered field of single-pixel stars visible behind ship/enemies/bullets, in front of the wall/background layer, with a visible brightness gradient (dimmer/slower stars vs. brighter/faster ones) as scroll speed changes — including on backward/vertical scroll, since speeds are signed.
  3. Sanity-check star density/positions roughly match what's visible in the normal Hatari ST window at the same moment.
  4. Confirm the occlusion check works: no stars should render on top of wall-tile or background pixels — check especially near wall edges where a star's position crosses in/out of tile coverage as it scrolls.