How it works · write-up
Sprite Stream terrain-masked worm segments
Current capture note (2026-10-03): ordinary object sprites are now captured at
their executed blitter entries, including $1912 for terrain masking. Earlier
dispatcher-hook descriptions below document the original investigation. This
preserves the tile-occlusion repair while also covering conditional wrappers;
see Level 4 tongue and Drone rendering.
Problem
The Sprite Stream renderer omits Level 2 worm segments drawn by the game's terrain-masked draw procedure. The original renderer shows the worm entering the upper-right tube; the Sprite Stream renderer leaves the same area empty.
This is a rendering fidelity defect. It does not affect the native autopilot's world observations, but it makes Sprite Stream AVI and the debug view disagree with the game.
Reproduction material
- Recording:
xenon_tools/run_logs/l2-transfer-climb-20260921-r232.validation/l2-transfer-climb-20260921-r232.x2events - Original AVI:
l2-transfer-climb-20260921-r232-original.avi - Sprite Stream AVI:
l2-transfer-climb-20260921-r232-sprite.avi - Map reference: level2-map.html
- Focused, self-contained comparison: level2-worm-terrain-mask.html
Open the recording with the current native renderer:
python xenon_tools\replay_ui.py xenon_tools\run_logs\l2-transfer-climb-20260921-r232.validation\l2-transfer-climb-20260921-r232.x2events --kernel-backend c
Seek to game frame 10464 (recorded VBL 49585). In the supplied AVI pair, the matching Sprite Stream image is AVI frame 1510 (one-based), VBL 49585. The original AVI capture has two VBLs of authentic latency: its matching image is AVI frame 1511, recorded VBL 49587, while replay VBL remains 49585.
The affected on-screen segment is formation_follower/- #22904:
| Property | Value |
|---|---|
| Object address | 0x03B244 |
| SpriteData address | 0x0553D8 |
| Object status | 0x0118 |
| Draw procedure | 0x000ECC |
Replay UI SCREEN pos |
(225, 24) |
| Bounds reported by Replay UI | (220, 19) through (231, 30), 11 x 11 logical pixels |
| Matching AVI crop | (440, 38) through (462, 60), 2x logical coordinates |
#22907 is another segment of the same worm chain and also uses 0x000ECC.
The focused frame uses #22904 because its bounds identify the visible
upper-right worm segment unambiguously.
The original AVI contains the diagonal worm pixels in this crop. Sprite Stream
contains the tube/background but none of those pixels. A scan of on-screen
0x000ECC objects in the recording found 858 observations: 845 have visible
differences and 13 happen to be fully or partially hidden at that instant.
Original game behavior
ReVa analysis of the Level 2 memory dump (/level2-live/mydumpat0) established:
0x000ECCis a thunk to0x001912.0x001912is not the ordinary sprite blitter at0x0010A2. Before it writes sprite pixels, it calls0x0037D4.0x0037D4derives a per-scanline mask from the active tile map. It readsPTR_DAT_0004F004_tileMapBaseat0x0004F004, applies the coarse map offset at0x00000CCEand fine vertical scroll at0x00000CD6, resolves the adjacent tile-mask graphics, and writes mask rows beginning at0x0003D5AC.0x001912combines those rows with each worm row. A set bit preserves the existing framebuffer pixel, so the terrain can hide part or all of a worm segment while it enters a tube.
The related helper at 0x003582 performs equivalent tile-map traversal for
collision, which corroborates that this is terrain-derived data rather than a
second worm sprite or a simple draw-order effect.
Confirmed mask inputs
ReVa disassembly confirms that 0x0037D4 has no worm-specific input. Its
inputs are the draw position and height, the live tile-map cells, the tile-mask
graphics, and coarse/fine scroll. It builds two 16-pixel mask words per
requested row; the sprite blitter then consumes only the needed rows. The
collision helper at 0x003582 follows the same logic for a 48 x 25-pixel area.
The live map is a 3,000-word level map at the address held in 0x0004F004.
The level loader replaces that map when a level changes, and the two-player
switch code swaps it with the other player's map. The base terrain mask can
therefore be generated once per active map. If gameplay changes a destructible
cell, the corresponding area must be patched; a permanently static texture is
not correct after such a change.
What the mask actually contains (2026-09-22)
Disassembling 0x0037D4 and its cell helper 0x003832 (capstone, over
xenon_tools/level_capture/level-02.stram.bin) pins the rule down per cell.
a1 is walked to the map cell covering the draw position -- [$4F004] + $CCE,
plus ((y + $CD6) >> 4) * $28 for the row and ((x >> 3) & $FE) for the
column -- and two adjacent cells are resolved per row, matching the blitter's
32-pixel working window:
| map code | mask source | resulting occlusion |
|---|---|---|
0 (empty cell) |
constant table $3714, all $FFFF |
not.w -> 0000: none |
> 0 (unmasked tile) |
constant table $3654, all $0000 |
not.w -> FFFF: full |
< 0 (masked tile) |
[$4F008] + (code & $7FFF) * $10, word 0 of the tile's 12-byte row |
the tile's own AND mask |
Both constant tables were dumped from the snapshot and are exactly as stated.
0x001912's geometry contract is byte-for-byte the ordinary blitter's
(move.w $20(a0),d0 / move.w $24(a0),d1 / movea.l $16(a0),a0 / sub.w (a0)+,d0 /
sub.w (a0)+,d1), and the row count it asks the mask builder for is the
sprite's own height, so the mask covers exactly the sprite's footprint and
nothing else.
That table is the wall-tile layer the Sprite Stream already captures. An
empty cell emits no tile quad; an unmasked tile's quad is fully opaque; a
masked tile's quad carries its mask as atlas alpha. Verified exhaustively
rather than assumed: for all 2142 wall tiles of all five levels, the alpha of
the tile's packed-atlas rect equals the terrain mask 0x0037D4 would build
for that cell, pixel for pixel. (A masked tile stores its mask word twice per
12-byte row; the two copies are identical in every row of every masked tile in
every level, so "word 0" and "what the exporter turned into alpha" cannot
diverge.)
Current renderer gap
src/drawCommandStream.c only accepts the verified ordinary draw procedures:
0x0010A2, 0x000E24, 0x000E78, 0x001594, and 0x001CD6. It therefore
marks 0x000ECC as unrecognized and emits no quad.
Even if 0x000ECC were treated as the ordinary 0x0010A2 blitter, that would
only restore a full worm sprite. It would draw through terrain in cases where
the original mask hides part of the segment.
The desktop shader, src/shaders/sprite_atlas_fragment.glsl, samples only the
shared and level sprite atlases. The browser equivalent in
web/src/views/spriteStreamView.ts has the same two samplers. Their atlas alpha
handles a sprite's static transparent pixels, but neither renderer receives a
dynamic terrain mask, a mask flag, or mask coordinates.
Fix (implemented 2026-09-22): render order, not a mask
The earlier recommendation in this note was a world-space occlusion texture
per active map, with a terrainMasked flag, world coordinates passed to both
shaders, a sampling branch in each, and invalidation on destructible-wall
changes, level load and the two-player map switch. That is a second
implementation of the tile-code rules above, living next to the one the wall
renderer already has, plus lifecycle management for a texture that must stay
in sync with RAM.
None of it is necessary. The occluder is the wall-tile layer itself, which is captured every frame from the game's own tile draws, at the live scroll position, with the live map -- including destructible changes, level loads and 2P swaps, for free. What was missing was only that the terrain must come back on top of the worm:
DrawCommandStream_OnInstructionFetchacceptsREAL_DRAWPROC_TERRAIN_MASKED(0x000ECC) andREAL_DRAWPROC_TERRAIN_MASKED_B(0x001912, the routine's own address, asREAL_DRAWPROC_3Bdoes for the flash proc) and pushes the ordinary quad withterrainMasked = true.SpriteStreamFrame_Buildappends, directly after each terrain-masked quad, every wall tile of that frame the sprite overlaps -- a second copy of a quad already in the array. The tile layer's alpha is the mask (see above), so painting those tiles back over the sprite reproduces the occlusion exactly, and an empty cell emits no tile quad, so it hides nothing, exactly as the$3714table does. The frame's wall tiles are collected in one pass before the append pass, so this costs one extra walk of the command ring rather than one per sprite, and does not depend on the tile map having been captured before the object list.SPRITE_QUAD_FLAG_TERRAIN_MASKEDrides along for diagnostics and for a backend that might one day depart from painter order.
No shader change, no new asset, no world coordinates, no invalidation, and it works in every renderer that paints the quad array in order (desktop GL and the browser view both do).
Note that terrainMasked now decides whether extra quads are emitted, which
makes it a field every push path must set. PushMaskedSpriteScaled and
PushFlatColorQuad initially did not, and a recycled ring slot leaked a stale
true into stars and scaled quads -- 328 of 803 flagged quads over 80 Level 2
frames, pulling tile copies over 157 stars. Fixed by resetting it in both,
next to the identical resets of kind, flatColorIndex and starKind.
Draw order in the original, and why it constrains the fix
The game has no depth ordering for these objects at all: it walks its draw
list and blits, so whichever object comes later in the list is on top. The
capture hook fires per object at that same dispatch (DRAWPROC_DISPATCH_PC),
so the quad array is the sequence of blits the 68000 performs, and measuring
the array measures the original.
Measured on the Level 2 checkpoint, the player ship was drawn before all worm segments in 60 of 60 frames in one stretch and after them in 77 of 80 frames in a later one. The order is not a property of the object types; it follows allocation and recycling of the list slots, so the same chain can end up in front of or behind the ship after a respawn. A single chain also interleaves the two blitters -- in one captured frame, quad indices 121-122 were terrain-masked segments, 123-127 ordinary ones and 128-131 terrain-masked again, all one diagonal worm.
Two consequences. First, "correct z-order" here means nothing more than "the order the list had", which is exactly what capture order gives and what this fix preserves. Second, it is what makes the alternative below unattractive: anything that moves these quads within the array is wrong for some frames, because there is no rule to move them by.
The z-order this replaced
The first implementation ordered terrain-masked quads beneath the tile layer instead -- a separate pass between the background mosaic and the tiles. That is simpler, and it occludes just as exactly, but beneath the tiles also means beneath every ordinary sprite, and that turned out to matter constantly rather than rarely: a worm chain mixes terrain-masked and ordinary segments, and consecutive segments overlap each other every frame. Measured against the game's own rendering, 88 % of the residual difference (9692 of 14424 pixels over a 150-frame recording) was two segments of one worm drawn in the wrong order. Re-emitting the occluders instead leaves the sprite where the object list put it; that residual drops to 96 pixels.
Where this is still not identical to the original
The original preserves whatever is already in the framebuffer where terrain
is opaque -- normally the tile, but a sprite drawn earlier in the object list
if one happens to be there. A re-emitted occluder is a whole 16x16 tile (a
quad's atlas rect is re-resolved from spriteId on the render side, so a
cropped sub-rect cannot be expressed), so where an earlier sprite had drawn
inside one of those tiles we paint the tile over it. That needs an ordinary
sprite and a worm segment to share a tile, and the ship to be drawn before
that segment (which happens -- see the draw-order section above): it did not
occur once in the recording below, nor in 80 further frames sampled live, where
the closest approach was 17 pixels.
Alternatives, if this is ever not good enough
Ordered by cost. None of them is needed for the defect this note is about; they differ in how they handle the two residuals (the occluder painting over an earlier sprite, and the top-edge case below).
A'. Crop the occluder copies to the sprite's footprint. Removes the
first residual almost entirely -- the repaint would cover only the pixels the
terrain has to hide, not the whole 16x16 tile. Needs the quad to carry its own
atlas rect instead of the render side re-resolving it from spriteId; a small
protocol change, no shader work.
B. World-space occlusion texture per active map. The note's original recommendation: generate a one-bit world mask from the tile map and the tile masks, pass each terrain-masked fragment's world coordinate, discard where the mask is set. Per-fragment, so no z-order effect at all. Costs a second implementation of the tile-code rules, a sampler and a coordinate in both shaders, and invalidation on destructible-wall changes, level load and the two-player map switch. Note it is not how the original works: the original builds its mask per draw from the live map, so a per-level texture is only equivalent while the map is immutable.
C. Stencil or depth pass from the tile layer. Same per-fragment properties as B, but the occluder stays the captured tile layer, so there is no second implementation and nothing to invalidate: the tiles write a stencil (or depth) value, terrain-masked quads test against it. Costs a pass in both backends. This is the option to reach for if the whole-tile repaint ever shows.
D. Reproduce 0x0037D4 per draw. Run the original's own mask builder in
the capture hook against ST memory and ship the resulting mask rows with the
quad (about 4 bytes per row). The only option that is bit-exact, because it is
the only one that performs the same reads -- including the wrapped ones behind
the top-edge residual. Costs a protocol extension and a mask sampler in both
shaders.
Validation performed (2026-09-22)
- Mask equivalence: the 2142-tile check described above, over all five level captures.
- Ordering, live: with the browser build, a frame's quad array reports
background [0..0] / terrainMasked [1..2] / tile [3..29] / other [30..49] / statusbar [50..69]. - Occlusion, live, end to end, in Level 2: the browser build started from
l2-f10377-freshstate.savthrough the new?snapshot=switch (xenondoc/HATARI_BUILD_RUN.MD), which reaches the worm/tube section without playing there. Terrain-masked quads appear (up to 10 per frame). For the worm segment at screen (233, 42) on that state's game frame 88 -- the same upper-right tube this note is about -- one captured frame's quads were replayed three ways against the live renderer, with the live frame hook frozen so the comparison could not race the emulator:
| rendering | difference from the shipped order, in a 120x100 crop |
|---|---|
| without the terrain-masked quads (the old build) | 2248 px -- the whole segment was missing |
| shipped order: terrain-masked under the tiles | -- |
| terrain-masked painted like an ordinary sprite | 244 px -- it draws over the tube |
Visually: the worm is absent in the first, drawn and clipped where it enters the tube in the second, and drawn across the tube's hatching in the third.
-
Level 1 unaffected: the same three-way replay on Level 1, with a throwaway build that marked every object-dispatch sprite
terrainMasked(only Level 2 installs0x000ECC, confirmed by searching all five RAM captures for the literal), moved 1068 pixels -- i.e. the mechanism does what it says on ordinary sprites too. That experiment was reverted before commit; with it reverted, no Level 1 draw is terrain-masked and the frame layout is byte-identical to before. -
Desktop AVI pair, whole frame, VBL-aligned (
fixed-*.avinext to the recordings;after-*.aviis the same run with the pass ordering of the previous section, kept for the comparison in it):hatari_dev.ps1 run -Snapshot l2-f10377-freshstate.savplus the automation protocol (LOAD_STATE,START_AVI, 150 xSTEP_GAME_FRAME,STOP_AVI) records the original and Sprite Stream views of the same run, so the game's own rendering is the reference and no second run is needed.avi_record.cwrites a<name>.avi.vblindex next to each file, one emulated VBL per AVI frame; the original view lags the Sprite Stream by exactly the two VBLs this note's reproduction section documents, so sprite VBL v pairs with original VBL v+2. That shift is not guesswork -- scanning it, the mean per-frame difference is ~20000 px at v+1 and v+3 and 28 px at v+2.
Over the whole 640x400 frame, all 595 aligned pairs:
| pairs | pixel-identical | median difference | worst frame | total | |
|---|---|---|---|---|---|
| sprite vs original | 595 | 559 (94 %) | 68 px | 272 px | 3472 px (0.0023 % of all pixels) |
Not one pixel is now missing from the Sprite Stream that the original draws (the "only the original is lit" count is zero), and the sprite-vs-sprite ordering residual is 96 px across the whole run.
comparison-frame429.png next to the recordings shows the worm body present
and clipped by the tube in both views (the same crop from a pre-fix run, kept
for contrast, has the segment missing entirely -- before the fix the same
regions differed by a median 1648 px).
What still differs, and where
One class, 36 frames of 595, 3472 pixels, and every one of them touches the top edge of the playfield: a worm entering from above, where the original stops drawing a few rows earlier than we do. Nothing differs anywhere else in the recording.
The likely cause is in 0x001912 itself. It calls the mask builder with the
unclamped y and only afterwards clamps the draw to row 0, and 0x0037D4
does lsr.w #4 on that value -- so for a sprite whose top is above the
playfield the mask rows are resolved from an unsigned-shifted, wrapped map
offset rather than from the cells on screen. That is a property of the
original's arithmetic, not of the terrain, so occlusion by the captured tile
layer cannot reproduce it; matching it would mean re-implementing 0x0037D4
for this one case. Ruled out as a cause: capture order (an implementation that
emitted the occluders from the capture hook, before the tile bands are
complete, produced byte-identical results).
Acceptance checks
- Done (with the live checkpoint rather than the recorded run): the Sprite Stream crop matches the original worm segment, clipped by the tube rather than drawn through it -- see the AVI pair above.
- Done in aggregate: every one of the 595 VBL-aligned frame pairs of the recording was compared whole-frame against the original view; 94 % are pixel-identical and the rest differ only in the top-edge class described above. No segment is omitted and no pixel the original draws is missing.
- Done for this recording: every difference from the original view sits
inside a worm segment's own bounds at the top edge -- no wall tile, HUD
element, star or ordinary sprite differs anywhere in the 595 frames. Nothing else can change
by construction either: with no
0x000ECCobject on screen no entry is terrain-masked, soSpriteStreamFrame_Build's extra pass contributes nothing and the array is exactly what it was.
Still not done: the same comparison against the recorded
l2-transfer-climb-20260921-r232 run in replay_ui.py, which needs that
recording (not in the repository).