How it works · write-up
Plan: Capture & render the starfield on GPU
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/+6bytes.wordAddr = drawBufferBase + byteOffset(the record's storedscreenByteOffsetalready 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
0x2b1adecompilation during implementation to be certain (the record field order — byteOffset, verticalSubpixelPhase, xBitMask — is already confirmed via your Ghidra plate comment on0x2aba).
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).
SpriteStreamVertexgains one more float:starColorGroup(-1.0= not a star / normal entry, else0.0–3.0). New vertex attribute, location 4, inCreateGraphicsPipeline(sdlGpuRenderView.c, alongside the existinginBgWrapaddition from the background work).- New uniform
vec4 starColors[4]inUniformBufferSpriteStream, computed once per frame inSdlGpuRenderView_Submitvia the same per-channel math asflashColor(sdlGpuRenderView.c:1935-1940), forSTRGBPalette[4]..STRGBPalette[7]. - Fragment shader (
sprite_atlas_fragment.glsl): ifinStarColorGroup >= 0.0, outputstarColors[int(inStarColorGroup)]directly (skip thetexture()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 existinginFlash/inBgWrappassthroughs. screentrace.c'sSpriteStreamVertexBuilder_AppendEntry: whenentry->isStarField, emit the quad's 6 vertices withstarColorGroup = entry->starColorGroupand 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— newDrawCommandStream_OnStarfieldDrawInstructionFetch, newDrawCommandStream_PushStarPixel.src/includes/drawCommandStream.h—DrawMaskedSpriteEntry.isStarField/.starColorGroup, new function declarations.src/screentrace.c— hook wiring (call the new instruction-fetch handler fromScreenTrace_LogRead, same as the background hook),SpriteStreamVertexBuilder_AppendEntrystar-quad path.src/sdlGpuRenderView.c—SpriteStreamVertex.starColorGroupfield, new vertex attribute (location 4,num_vertex_attributesbump),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
- Build, open the "Sprite Stream" debug window during gameplay with active scrolling.
- 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.
- Sanity-check star density/positions roughly match what's visible in the normal Hatari ST window at the same moment.
- 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.