Xenon 2

How it works · write-up

Sprite Stream terrain-masked worm segments

xenondoc/SPRITE_STREAM_TERRAIN_MASK.md · 20 KB · updated 2026-10-05

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:

  1. 0x000ECC is a thunk to 0x001912.
  2. 0x001912 is not the ordinary sprite blitter at 0x0010A2. Before it writes sprite pixels, it calls 0x0037D4.
  3. 0x0037D4 derives a per-scanline mask from the active tile map. It reads PTR_DAT_0004F004_tileMapBase at 0x0004F004, applies the coarse map offset at 0x00000CCE and fine vertical scroll at 0x00000CD6, resolves the adjacent tile-mask graphics, and writes mask rows beginning at 0x0003D5AC.
  4. 0x001912 combines 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:

  1. DrawCommandStream_OnInstructionFetch accepts REAL_DRAWPROC_TERRAIN_MASKED (0x000ECC) and REAL_DRAWPROC_TERRAIN_MASKED_B (0x001912, the routine's own address, as REAL_DRAWPROC_3B does for the flash proc) and pushes the ordinary quad with terrainMasked = true.
  2. SpriteStreamFrame_Build appends, 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 $3714 table 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.
  3. SPRITE_QUAD_FLAG_TERRAIN_MASKED rides 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.sav through 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 installs 0x000ECC, 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-*.avi next to the recordings; after-*.avi is 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.sav plus the automation protocol (LOAD_STATE, START_AVI, 150 x STEP_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.c writes a <name>.avi.vbl index 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

  1. 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.
  2. 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.
  3. 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 0x000ECC object on screen no entry is terrain-masked, so SpriteStreamFrame_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).