Xenon 2

How it works · write-up

Xenon 2: Megablast — Reverse Engineering & GPU Rendering Notes

xenondoc/XENON2.MD · 173 KB · updated 2026-10-06

This document summarizes what's been reverse-engineered about the Atari ST game Xenon 2: Megablast's rendering internals, and how this repo uses that knowledge to reconstruct live gameplay frames on a modern GPU instead of emulating the ST's own blitter. It's a snapshot of current understanding, not a spec — addresses and formulas below were confirmed against a specific captured ST memory/register snapshot (loaded in Ghidra as program /mydumpat0) and cross-checked against live gameplay capture data where noted; they may need re-verifying against a different build/level if things stop matching.

Everything here is investigative/derived knowledge about the original 68000 game binary. The Hatari-side C/.NET code that consumes this knowledge lives in src/ and hatari_dotnet/ and is described in its own comments — this file is the "why", not a duplicate of "what the code does".

1. The big picture

Xenon 2 draws every frame through three independent 68000 code paths, all running before the buffer swap each frame:

  1. Background/wall-tile pass — ST_DrawGeneratedBackgroundFromTileMap_FUN_00001dd0 (0x1dd0). Redraws the entire visible tile grid from scratch every frame (no incremental scrolling optimization). Fills each of a 20-column tilemap's cells either with an individually-authored 16x16 wall tile, or (where the tilemap cell says "empty") with a slice of a separately scrolling, pre-rendered background mosaic.
  2. Starfield pass — Starfield_UpdateAndDraw_48Stars (0x2b1a). Updates and plots 48 single-pixel stars after the background/walls are complete. The stars form four brightness bands with progressively faster vertical motion; see §3.4.
  3. Object pass — DrawObjectListCallProc2_FUN_00003ed8 (0x3ed8). Walks a linked list of game objects (ship, enemies, bullets, particles, formations, boss segments) and calls each object's own drawProc function pointer.

The exact main-loop order is background/walls (0x7a82), stars (0x7a86), then objects (0x7a9c). This ordering is also semantically important: the star routine refuses to overwrite nonblack background/wall pixels, while subsequently drawn objects can cover stars normally.

2. Object system (the "object pass")

2.1 MyObjectEntry struct (98 bytes)

+0x00  status
+0x02  updateProc   (function pointer, per-frame game-logic update)
+0x06  drawProc     (function pointer, called once per frame to draw this object)
+0x0a  proc3
+0x0e  prev         (linked-list pointers)
+0x12  next
+0x16  sprite       (SpriteData* -- ST address of this object's currently-assigned sprite)
+0x1a  aux1A
+0x20  x            (screen-space X)
+0x22  xSubpixelOrFraction
+0x24  y            (screen-space Y)
+0x26  typeState    (60 bytes -- union of GenericEnemyState / FormationLeaderState /
                      FormationFollowerState / BossSegmentState / RadialProjectileState)

2.2 Draw dispatch

DrawObjectListCallProc2_FUN_00003ed8 walks the object list; for each live object it does movea.l (0x6,A0),A1 (load drawProc) then JSR (A1) at PC 0x3ee4. At that exact PC, A0 = the MyObjectEntry* about to be drawn, with all fields already committed — this is the hook point (DrawCommandStream_OnInstructionFetch, src/drawCommandStream.c).

2.3 Known drawProc values

Address Name Behavior
0x10a2 draw_ship_maybe_FUN_000010a2 The generic masked-sprite blitter — used directly by most object types (ship, enemies, formations, boss segments, radial projectiles). Reads two color longwords/row from the sprite, AND-clears with a mask word then ORs in real color bits.
0xe24 (thunk) JMP 0x10a2 — same behavior as above.
0x1594 DrawFlashFrame_RevertToNormalDraw_FUN_00001594 One-shot "flash this object solid for a single frame" effect. See §2.4.
0xe78 (thunk) JMP 0x1594 — same behavior. Both 0x1594 and 0xe78 appear as live literal drawProc values — different install sites write one or the other (e.g. ShipUpdate_ProcessInputMovementCamera_FUN_00006734 at 0x67c6 writes 0x1594 directly; some enemy-list installers write the thunk 0xe78). Any code matching on drawProc value must check both.
0x1cd6 DrawPreshiftedBulletObject_FUN_00001cd6 Genuinely different routine, bullets only. See §3.5.
0x6cba DrawSideCannonShotObject_FUN_00006cba Player side-cannon composite. Calls $6cd0, which draws endpoint sprites normally but writes the long vertical body directly into the framebuffer. See below.
0x6432 WallTargetHitFlash_DrawCustomMaskOnce_FUN_00006432 One-frame wall-mounted-target hit silhouette. Its sprite field is a signed custom run-mask table for $2504, not SpriteData; see below.
0x39a2 NoOpDummyHandler_FUN_000039a2 Two-byte rts stub. Assigned to drawProc/updateProc/proc3 slots as a generic "disable this handler" idiom (89 call sites) — e.g. suppresses a thrust-flame particle's draw while not thrusting, without touching its sprite pointer. Capture code must skip this drawProc value explicitly, or every such object looks "drawn" every frame regardless of the game's actual intent.
0x5081a BossController_DrawComposite_FUN_0005081a Specialized level-1 eye-boss renderer. The controller's own sprite field is zero. It draws a 6x7 grid of ordinary level-graphics tiles through $21ac, then directly invokes $10ae for a three-frame animated center sprite. See below.

The boss draw procedure explains a formerly silent hole in the semantic renderer. The generic object-dispatch hook saw drawProc=$5081a, but returned immediately because the controller's sprite pointer is zero. There was consequently no sprite address on which to perform the usual atlas lookup or emit an atlas-miss diagnostic, even though the original routine subsequently drew the entire boss through direct calls.

Exact disassembly of $5081a shows two fully prepared capture points:

  • At $50838, A1=$509ee, D0=5, and D1=6 describe a 6-column by 7-row table consumed by BossComposite_DrawTileGrid_FUN_000021ac through thunk $e9c. D2=self->y-$cd0 is the top screen Y and D3=$38 is a framebuffer byte offset, equivalent to screen X 112. Each nonzero word uses the same tileGraphicsBase + (code & $7fff)*$10 address formula as wall tiles; bit 15 selects the masked form. Every referenced tile is already present in the packed atlas.
  • At $5085e, A0 is one of the ordinary masked SpriteData records $56316, $563c6, or $56476, selected from the table at $50866; D0=152 and D1=self->y-$cd0+66 are its anchor coordinates. This call goes straight to $10ae through thunk $ea2, bypassing object dispatch a second time.

DrawCommandStream_OnBossCompositeDrawInstructionFetch captures the grid at the common $21ac callee entry and the center at its prepared call site. Capturing the common callee, rather than only level 1's $50838 caller, also covers relocated level-specific controllers. For example, level 2's normal boss draw procedure is loaded at $510ce, conditionally returns while scroll $cd0 >= $160, and later calls $21ac with a 6x9 table at $51478. The callee hook therefore draws nothing while the boss is hidden and automatically captures the body once the real call executes. No wire-protocol or Python-renderer special case is required. Grid cells use distinct synthetic stable identities under $fffb0000, mixing the live table address with row-major cell; the animated level-1 center retains the real controller identity.

The player side cannon exposed another specialized hole. $6cba loads the shot-width variant from object+$42 into D7, passes the object's screen position in D0/D1, and calls DrawSideCannonShotComposite_FUN_00006cd0. That helper makes two ordinary $10ae endpoint-sprite calls, but constructs the tall body itself from a 20-byte record at $6e22 + D7*20: one 32-bit AND mask followed by four 32-bit Atari color planes. The three records have 8, 12, and 16 leading opaque pixels respectively; the remaining bits are transparent. It repeats that same one-scanline pattern upward for object+$28 + 1 scanlines, writing the active framebuffer directly. Therefore the generic object hook captured only a small object sprite while the two long shots disappeared.

DrawCommandStream_OnSideCannonDrawInstructionFetch saves the parent/variant at $6cd0, captures the two fully prepared endpoint calls at $6d0c/$6d2c, and decodes the opaque body row after those calls at $6d30. The generic $3ee4 hook deliberately skips $6cba's stale self->sprite, which the original procedure never reads (capturing it caused a white diamond to replace an authentic rounded endpoint). Each body run becomes one tall DRAW_ENTRY_KIND_FLAT_COLOR quad, so capture is exact with the active level palette, requires no atlas asset or wire-format extension, and remains bounded to at most 16 commands per shot. Body runs use distinct stable synthetic identities under $fffa0000; the two endpoint sprites use $fff90000. Sharing one identity between these quads would violate the interpolator's one-quad-per-identity matching rule.

Wall-mounted targets use another direct-framebuffer path for their one-frame hit response. The hit handler installs $6432, which immediately restores drawProc to the $39a2 no-op and prepares DrawCustomRunMaskToFramebuffer_FUN_00002504. In this state MyObjectEntry.sprite (object+$16) is not a sprite header: it points to a row-major table of signed 16-bit cell codes. Width and height minus one are at object+$3e/$40; MyObjectEntry.x/y are the world anchor, with $cd0 subtracted from y to obtain screen space. A zero code is transparent, a positive code is a solid palette-index-7 16x16 cell, and a negative code uses (code & $7fff) as a mask-tile index under the live level tile-graphics base at PTR_DAT_0004f008.

The generic object hook recognizes $6432 before treating sprite as SpriteData, while the common $2504 callee hook reconstructs those cells as either flat-color quads or flash-tinted atlas masks. This same hook covers level-specific boss hit procedures, such as level 2's loaded procedure immediately following $510ce, without hard-coding their caller addresses. It captures the pink wall-target silhouette without the misleading invalid-sprite warning previously produced from the run-mask table.

The level-2 comparison also exposed the inverse failure mode: the generic $3ee4 hook previously logged an unrecognized drawProc but still rendered self->sprite whenever that address happened to exist in an atlas. The hidden $510ce controller retains $ed5a in that field but returns before drawing it, producing a phantom white projectile at (157,89) in Sprite Stream. Unknown draw procedures are now diagnostic-only and are not rendered at object dispatch. Verified ordinary procedures remain captured there; specialized procedures are captured only at the low-level calls that actually execute. This makes conditional draws conservative and prevents stale object fields from becoming visible artifacts.

2.4 The flash effect, precisely

DrawFlashFrame_RevertToNormalDraw_FUN_00001594 draws the exact same mask/silhouette shape as the normal blitter, but not the same colors. Confirmed via disassembly comparison: it reads only one mask word per row (vs. the normal blitter's two color words) and, at every opaque (mask-bit-0) pixel, unconditionally forces all four bitplanes to a fixed value — plane3 = mask & plane3 (clears to 0), planeN = planeN | ~mask for N=0..2 (sets to 1). Nothing is preserved from the sprite's real color or the framebuffer's prior content at opaque pixels; at transparent pixels every op is a genuine no-op (background correctly left alone). With standard ST bitplane weighting (plane0=1, plane1=2, plane2=4, plane3=8) this is deterministically color index 7, every time, for every flash-drawn sprite — not background-dependent, not an approximation.

Immediately after doing this one draw, it sets self->drawProc back to 0x10a2, so the effect is always exactly one frame. Installed from several unrelated call sites (ship-fire, and a couple of enemy-list loops) — it's a generic "flash this object" utility, not ship-specific.

2.5 Update dispatch (the "update pass")

Before the draw pass described above, processes_linkedLists_draw_ship_FUN_00003e42 (0x3e42, called once per frame from the main loop at 0x7a9c, immediately before the background/wall draw call at 0x7a82) runs an update pass over the same 5 object lists (spaceship, PTR_DAT_00000b4c enemies, PTR_DAT_00000aea, DAT_00000bae, DAT_00000c10), calling each live object's updateProc via UpdateObjectListCallProc1_FUN_00003ec4 (0x3ec4) before that same list gets its drawProc pass. Structurally identical to the draw dispatch: while (entry->status != 0) { entry->updateProc(); entry = entry->next; }, walked with A0 = entry pointer. The same nonzero-status test at $3ED8 gates the draw-list traversal. It means "live linked-list member," not "visible inside the hardware viewport": an object can be updated and dispatched below/above 320x192, and a formation movement leader can deliberately carry a no-op drawProc while its visible followers remain active.

The indirect call itself is JSR (A1) at PC 0x3ed0 (A1 loaded from (0x2,A0) — the updateProc field — at 0x3ecc), the update-pass analog of the draw pass's 0x3ee4 hook point. One important difference from the draw pass: entry->next (offset 0x12) is fetched into a temporary before updateProc is invoked, so the traversal survives an updateProc that frees or unlinks the very entry it's running on (e.g. DestroyObject_UnlinkAndFree_FUN_0000107c, §2.6) — no use-after-free of entry->next once the entry is recycled.

Per-frame ordering, full picture: update pass (all 5 lists) → background/wall draw → starfield → draw pass (all 5 lists) → ScrollTriggeredSpawnDispatch_FUN_00003dc2 (spawns). So an object spawned this frame is drawn this frame (its first updateProc call happens next frame), and an object's updateProc output (new x/y, new sprite, possibly a changed drawProc) is what the same frame's draw pass actually renders — update and draw are never a frame apart for the same object.

2.6 Known updateProc values

Address Name Behavior
0x661c ShipMovement_ApplyJoystickAndScroll_FUN_0000661c Applies joystick $A00 to the ship. It saves the pre-move position in $4E4/$4E6; updates signed steering $CDE in [-6,+6] (neutral decays one unit, reversal clears the prior sign); scales horizontal displacement by ship speed $C9E (level 0 halves, level 1 uses 6 pixels while held, level 2+ uses 9); clamps x to 14..304; applies vertical speed 3+$C9E; clamps y to 16..176; and emits top/bottom camera intent +1/-1 through $CDA.
0x6734 ShipUpdate_ProcessInputMovementCamera_FUN_00006734 Player ship: input handling, movement, fire-trigger (spawns a bullet via 0x65a4, installs the flash drawProc), camera-follow (16-entry smoothed position history), respawn/invulnerability state machine, sprite-frame selection.
0x4ea0 CompassOrbitThenDropCollision_Update_FUN_00004ea0 Deterministic eight-direction movement shared by enemies and released pickups (a MYOBJECT OVERLAY onto typeState.genericEnemy). Object +$28 is a signed phase timer and +$2a is direction 0..7. Positive phases retarget toward screen (160,100) every eight frames through CompassDirectionTowardVector_FUN_000039a4; entering radius 20 sets the timer to -34. Negative phases advance direction clockwise every four frames through the (dx,dy) table at $4fd8. Timer zero falls straight down by eight pixels per frame and destroys the object at Y>=200. Collision dispatch uses object +$30 as an index into the terminal-handler table at $5040. Released collectibles therefore have the same movement but are distinguished by payload/status plus terminal semantics; updateProc=$4ea0 alone is not a safe hostile-object classification.
0x9a40 ScriptedSineMotionPeriodicSpawner_Update_FUN_00009a40 Scripted sine-motion plus animated probabilistic-spawn update. In the reproducible first-level capture, direct $9a40/$3eec objects with sprites $26770..$26c40 are the destructible pickup carriers entering from the left; destroying one releases a $4ea0/$39a2 collectible. The procedure is reusable, so its address alone does not imply this visual role.
0x4f7a2 WallMountedTarget_Update_FUN_0004f7a2 Destructible wall-map target/controller. It stores screen collision bounds at object offsets $36..$3c: left=x+10, top=worldY-$cd0+4, right=x+28, bottom=worldY-$cd0+28; animates the wall tile-map cells addressed through offset $46; and periodically enters the shared object-spawn path. Its hit proc is vector $0f32 → multi-hit handler $63a8. Raw object y remains a level/world coordinate and cannot be used directly for aiming.
0x4fae2 (level-2 overlay) Level2WallMountedHorizontalShooter_Update_FUN_0004fae2 Level 2 replacement for the destructible wall emplacement. It rejects objects behind the camera, derives screen collision bounds from world y-$cd0, animates the wall cell addressed through object +$46, and at animation state 7 enters the shared $e4e spawn path using x+8,y-$cd0+8 and the level direction tables at $4f048/$4f04a. Live identity traces tie this routine and hit proc $0f32 to the continuous $4180 projectile sources; the autoplay symbol is XENON_UPDATE_PROC_LEVEL2_WALL_MOUNTED_HORIZONTAL_SHOOTER.
0x502c2 FormationFollowerUpdate_ChaseNextInChain_FUN_000502c2 Formation follower: copies position/sprite/state from entry->next (the next-spawned formation member) each frame — "swarm follows one path." Self-destructs when the entry ahead hits the formation end-marker (status==0xbc). Also re-selects sprite frame from an 8-entry rotation table keyed by a facing-angle byte.
0x503b4 FormationLeaderUpdate_ScrollLocked_FUN_000503b4 Formation leader: position locked to the level scroll offset (self->y += wall-scroll delta each frame) — the whole formation is level-authored, not independently AI-driven. Calls the scripted sine-motion core at $9aca.
0x50478 BossSegmentUpdate_ChaseAnchorOrPredecessor_FUN_00050478 Per-segment boss update: copies position/sprite/state from a "predecessor" pointer (offset 0x1C, a third distinct chain-link field separate from prev/next and the formation system's target/0x56), building a trailing snake-body motion. The segment directly attached to the boss controller instead computes a fixed screen-relative anchor.
0x505aa BossSegmentedSpawn_Init_FUN_000505aa Boss controller's own updateProc: one-shot (guarded), spawns a chain of 8 body segments sharing updateProc=BossSegmentUpdate_ChaseAnchorOrPredecessor_FUN_00050478, drawProc=vector 0x0e24 (plain blit), proc3=vector 0x0f3e → 0x6208, the one-hit-kill hit-reaction handler (§2.9) — corrected this session; an earlier pass had mislabeled 0x0f3e as thunk_FUN_00003af4 (a different, unrelated address, slot 31 not 48). Each of the 8 segments dies in exactly one hit.
0x5025a RadialBurstProjectile_Update_FUN_0005025a Small radial burst particle spawned by a formation follower's proc3 timer on expiry. Reads a facing-angle byte, applies cos/sin from SineTable_8bit_00009c80 (÷4) each frame — straight-line constant-velocity motion. Self-destructs once off-field.
0x613e FollowTargetPosition_FUN_0000613e Copies self->x/y from *(self->target) (offset 0x56, a MyObjectEntry*) each frame — an attached-part/weapon tracking its parent, or a homing-style dependency.
0x4180 (thunk at vector slot 40, 0xf0e) DirectionalProjectile_UpdateDamageShip_FUN_00004180 Moves 16.16 fixed-point x/y at offsets 0x20/0x24 using one of 16 signed 2.14 direction vectors selected by object +$2a, scaled by the per-instance word at +$54, for four substeps per canonical update. It rejects positions outside 320×192, recomputes its collision box, tests against the ship, subtracts 4 from the player shield at $cc6, and installs the ship's one-frame flat-color hit flash. The routine is generic: observed shots include horizontal, vertical, and diagonal radial-fan members. $9a40/$3eec with the first-level $26770..$26c40 visuals is instead the pickup carrier.
0x4902 PlayerCannonAttachment_Update Installed Cannon attachment for attachment code $0034 / shop item 15. It follows the ship using signed attachment offsets in MyObjectTypeState at object +$3e/+$40. When firing, it allocates a projectile with updateProc $4e50, copies attachment X, and initializes projectile Y to attachment Y-11. In the validated Level-4 loadout the live offset is (shipX+26,shipY+16).
0x4e50 PlayerCannonProjectile_Update Moves the Cannon projectile upward by 10 pixels per canonical frame, then enters the rectangle collision path through $3908. With the observed attachment offset, its first collision-tested anchor is (shipX+26,shipY-5). This distinct offset firing lane is required to aim at narrow wall targets without placing the ship itself against the wall.
0x6086 ordinary player-bullet update Loads the projectile anchor from object +$20/+$24 into D0/D1 and calls $3950. $3950 scans active objects and tests that point against each object's inclusive collision rectangle at +$36..+$3c; neither routine queries the tile map. Walls therefore constrain the ship's firing station but are not bullet-ray occluders.
0x4f244, 0x4f24e (level-3 overlay) Level3ScriptedSineSwarmVariantA/B_Update_FUN_0004f244/0004f24e Two Level-3 enemy entries which call vector $e8a into the shared $9a44/$9aca scripted-sine core. Their only local difference is the facing-animation table ($4f278 versus $4f298), indexed by the low word of self->typeState.scriptedSineMotion.phase, before both jump through vector $e60. They therefore use the existing scripted-motion capture and predictor rather than a new movement model.
0x51260 (level-4 overlay) Level4DirectionalWallShooter_Update Destructible 2x2 wall shooter with a world-coordinate anchor. It writes a 28x28 screen interaction rectangle and animates the mounted cells through object+$46. Byte +$5e advances by $10; after its carry starts the six-phase state at +$28, phase 4 tail-calls $e4e to allocate a $4180 directional projectile. Status $d0 fires left from (x+10,y+14) with trajectory 6, while $d4 fires right from (x+18,y+14) with trajectory 2; both use speed scale 6. Its damage callback is $513bc; surviving hits install one-frame flash draw $51442, while destruction opens/replaces the wall cells and awards 400 points.
0x50996 (level-3 overlay) Level3ReflectingDirectionalProjectile_Update_FUN_00050996 Calls vector $f0e into DirectionalProjectile_UpdateDamageShip_FUN_00004180 first. If status is $14, it then changes self->typeState.genericEnemy.directionIndex and selects sprite $55562 or $555b8 when X crosses 128/192. This is the same directional projectile with Level-3 X-boundary reflection and presentation/state decoration, not a new enemy family.
0x50690 (level-3 overlay) Level3ExpandingFormationController_Update_FUN_00050690 Controls a seven-member horizontal front. self->typeState.genericEnemy.directionIndex at +$28 is member spacing and self->typeState.genericEnemy.directionDelta at +$2a is normally +2/-2. When activated near the player, RNG selects a maximum spacing of 10, 12, or 14; spacing expands to that limit, contracts to zero, waits, and repeats.
0x5073c, 0x507b2 (level-3 overlay) Level3ExpandingFormationLeader_Update_FUN_0005073c, Level3ExpandingFormationFollower_Update_FUN_000507b2 Both fold $cd8 into screen Y, then set X to self->typeState.genericEnemy.baseX + self->typeState.genericEnemy.memberOrdinal * controller->typeState.genericEnemy.directionIndex. The observed base is 304 and ordinals are -7..-1. Autoplay reserves the exact union of all legal spacings (leader X 206..304) instead of extrapolating the oscillating integer positions as velocity.
0x107c DestroyObject_UnlinkAndFree_FUN_0000107c The "Destroy" updateProc — every dying object's terminal state before being recycled by AllocateOrRecycleObjectEntry_FUN_00002be4.
0x39a2 NoOpDummyHandler_FUN_000039a2 Same rts-only sentinel as §2.3 — also legitimately installed as updateProc (and proc3), not just drawProc.

MyObjectEntry.typeState (§2.1, 60 bytes) is already modeled in Ghidra as a union (MyObjectTypeState) of per-updateProc-family structs — GenericEnemyState, FormationLeaderState, FormationFollowerState, BossSegmentState, RadialProjectileState, ScriptedSineMotionState — matching the updateProc families above one-to-one; status selects which union member is live for a given object, the same way it selects drawProc behavior.

2.6.1 Pickup semantics encoded in RAM

xenon_tools/pickup_catalog.py decodes and joins four original-game data sources: 19 {uint16 code, uint32 animation} records at PickupCodeAnimationTable_00003f96; 14 CMP.W/BEQ.W records in InstallOrUpgradeShipAttachmentByCode_FUN_0000508c; 25 shop purchase-handler and display-name entries at $10E2A/$10EA8; and the shop icon-animation pointer table at $10F0C. Matching handler addresses gives the attachment name directly. For pickups whose installed code differs, matching the released animation's sprite sequence to the shop icon loop supplies the shop name. The latter permits extra repeated final frames because the shop deliberately dwells on that frame.

This extracts the following mapping from assets/stram.bin; names are not inferred from atlas appearance:

Pickup/status code Animation Semantic name Class
$001C $002D0C FLAMER attachment
$0020 $002DDE LASER attachment
$0028 $002DAE BOMB attachment
$002C $002D8A DRONE attachment
$0034 $003000 CANNON attachment
$0038/$003C $0030A2 / — MINE attachment
$0044 $003054 REAR SHOT attachment
$0048 $004008 HOMING MISSILE attachment
$004C $0031F2 ELECTRO BALL attachment
$005C $002D6C unresolved ship upgrade
$0068 $003234 POWERUP ship upgrade
$006C $003270 AUTOFIRE ship upgrade
$0074 $0032E8 SPEEDUP ship upgrade
$007C $003360 DIVE ship upgrade
$0080/$0098 $00339C / — SIDE SHOT attachment
$0088 $003414 ZAPPER one-shot smart bomb
$0090 $00348C MISSILE LAUNCHER attachment
$0094 — FORWARD SHOT attachment
$00A8 $002CE8 HEALTH POWER 1 ship upgrade
$00AC $002CC4 HEALTH POWER 2 ship upgrade
$00B0 — FLAMER attachment
$00C0 — DOUBLE SHOT attachment

The duplicate semantic names are real table relationships: for example, released SIDE SHOT uses $0080, while the installer dispatcher also accepts $0098. Cash follows separate paths and is kept as explicitly observed evidence rather than fabricated into the 19-record table. Existing captures contain $0060 with animation $002E9E; run 29 additionally identifies large cash status $0018 using animation script $002E3E (sprite family $020F6E..$0217DC).

Two names require evidence outside those four tables. $0088 is the literal Z animation; original-game documentation names ZAPPER as the screen-clearing weapon, and an independent play report describes the Z token as an instantaneous smart bomb. $0094 is identified from code: handler $5332 installs it in the primary attachment slot, assigns update procedure $5DC8, and that procedure calls the base forward-shot firing path $6990 on fire input. $0094 is also recreated when respawn finds that primary attachment slot empty.

2.6.2 Camera limits and finite backward scrolling

The ship routine does not infer backward-scroll availability from motion. At $66c6, when the ship is clamped to bottom Y=$b0, DOWN writes -1 to $CDA only when current camera position $CD0 differs from the live backward boundary $CEA. Equality is therefore the game's exact "cannot scroll farther backward" state.

The per-frame clamp beginning at $7b5a computes candidate = $CD0 - $CDA, clamps the candidate to [$CE8,$CEA], stores the effective applied scroll delta at $CD8, and commits the clamped position to $CD0. $CEE is initialized to $10; during ordinary forward progress the loop contracts $CEA to at most candidate + $CEE. This is a rolling 16-pixel backtracking leash, not a generic table of large predefined scroll regions. Level/boss code can override $CE8 and $CEA (the first-level initializer at $508a8 sets them to $0000 and $11ff). Respawn copies the saved scroll position into both $CD0 and $CEA.

Address ReVa symbol Meaning
$CD0 CameraScrollPosition_cd0 Current level/camera Y position.
$CD8 CameraScrollAppliedDelta_cd8 Effective delta after forward/backward clamping.
$CDA CameraScrollRequestedDelta_cda Requested delta produced by ship/camera logic.
$CE8 CameraScrollForwardLimit_ce8 Forward (lower-coordinate) clamp.
$CEA CameraScrollBackwardLimit_cea Live backward (higher-coordinate) clamp.
$CEE CameraBacktrackWindow_cee Rolling backward window, normally 16 pixels.

2.7 Spawn pipeline and the shared vector table

Two independent trigger mechanisms feed the same spawn pipeline:

  • Scroll-position-triggered spawns: ScrollTriggeredSpawnDispatch_FUN_00003dc2 (0x3dc2, called once/frame right after the draw pass, 0x7aac) walks level script tables (PTR_DAT_0004f038 forward / PTR_DAT_0004f014 backward, 7-word records terminated by -1). When the live scroll position crosses a record's trigger X, it calls thunk_FUN_0004fd94/thunk_FUN_0004fb42, which indexes an 11-entry direct dispatch table (EnemyTypeSpawnDispatchTable_11entry_0004fd9e, indexed by a type ID in D0, code[i]() not a JMP-thunk) to reach one of 11 per-enemy-type 10-byte spawn stubs (LEA EnemySpawnParams.L,A2 ; JMP 0x00000e72).
  • Formation/boss spawns: SpawnEnemyFormation_FUN_00050050 (up to 5x/frame, trigger flags DAT_00000d9c[0..4]) and BossSegmentedSpawn_Init_FUN_000505aa/the level-init boss spawn (0x508a8) construct MyObjectEntry chains directly and set updateProc/drawProc/proc3 via literal move.l #addr,(0x2/0x6/0xa,An) — bypassing EnemySpawnParams and the vector table entirely.

Every enemy-type's EnemySpawnParams record (26 bytes, one per type, spaced 0x1A apart from 0x4f066) stores proc1Vector/proc2Vector/proc3Vector as addresses into the shared 56-slot JMP trampoline table starting at ScriptedSineMotionPeriodicSpawner_UpdateProc_Thunk_00000e1e (0x0e1e-0x0f6d, confirmed genuine code, not data) rather than raw function pointers — SpawnInit_ConfigureNewEntry_FUN_00003ac0 / SpawnInit_RepeatAllocate_FUN_00003b18 copy these vector addresses straight into the new object's updateProc/drawProc/proc3 fields. This table is reused for drawProc values, RNG/allocator helpers, and other install-by-ID sites too — it isn't exclusively an updateProc table. A full 56-slot resolution (target address + known name, done by reading the table's live bytes and resolving each thunk) is recorded as a Ghidra plate comment on 0x0e1e; roughly 30 of the 56 slots are still bare, un-analyzed FUN_ stubs and are the highest-value next targets for updateProc discovery, since — like thunk_FUN_00004180 — they can be installed purely via a copied vector-index field and have zero static call xrefs, making them invisible to ordinary call-graph analysis.

2.8 RNG state — a hard constraint on frame extrapolation

Two independently-advancing pseudo-random generators are reachable from object update code:

  • Next_random_FUN_000027fe (0x27fe, also vector slot 7) — the gameplay RNG. Confirmed called from at least the formation-follower proc3 burst-spawn timer (randomizes a burst's initial angle) and star initialization (Starfield_InitializeRandomPositions, level-init only, not per-frame).
  • $9aca (also vector slot 50) is not RNG. It is the scripted sine-table position core described in §2.10 below.

A separate, unrelated LFSR at 0x2e720 ("Advances the driver's pseudo-random/noise LFSR state") drives audio noise/effect modulation — not gameplay state, presumably irrelevant to visual frame extrapolation.

Any updateProc that consumes gameplay RNG cannot be safely re-run to synthesize an in-between frame: replaying it advances the shared RNG stream, so either the real next real frame's random draw gets consumed early (diverging gameplay from the original run) or the synthesized frame reuses/duplicates a draw (visually detectable — e.g. a burst's angle repeating). Any interpolation/extrapolation scheme needs to identify, per updateProc, whether it's a pure function of already-known state (position/velocity fields — safe to extrapolate, e.g. RadialBurstProjectile_Update_FUN_0005025a's constant-velocity motion or FormationLeaderUpdate_ScrollLocked_FUN_000503b4's scroll-lock if the RNG call inside it turns out to not affect visible state this frame) versus one that reads RNG and therefore must only ever run once per real game frame (unsafe to extrapolate without accepting a visible discrepancy).

2.9 Vector table static sweep — results

All ~32 previously-unexplored slots in the JMP trampoline table starting at ScriptedSineMotionPeriodicSpawner_UpdateProc_Thunk_00000e1e (§2.7) have now been decompiled and categorized (full per-slot detail lives in that address's Ghidra plate comment). Headline results:

  • Only ~9 of the 56 slots are genuine per-object procs installed purely via a copied vector index with no static call-graph edge (the same "invisible to Ghidra" profile as DirectionalProjectile_UpdateDamageShip_FUN_00004180): a probabilistic aimed-projectile spawner updateProc (0x9a40, slots 0/18), FollowTargetPosition_FUN_0000613e (already known), an animation-sequence starter (0x34ea, slot 5), a self/children terminal-destroy utility (0x34fa, slot 6), DestroyObject_ UnlinkAndFree_FUN_0000107c (already known), the directional projectile trajectory (already known), a silent pair/group despawn (0x4020, slot 49), and a minimal "animate only" updateProc (0x5fec, slot 52). The rest are shared subroutines reached through the same table but not object procs themselves.
  • A generic sprite-animation-sequence subsystem exists: FUN_000034c2 ("AnimationSequence_ Tick") decrements a per-object frame counter and, on expiry, either advances to the next frame of a 6-byte-record script ({spritePtr, duration}) or — if the script hits its terminator record — invokes a completion callback function pointer stored in that same record. FUN_000034ea ("AnimationSequence_Start") loads the first frame. Both operate on the ObjectAux1A_Packed union at struct offset 0x1a (a third interpretation of that union, alongside the already-modeled RadialProjectileAux1A/BossObjectAux1A). AnimationSequence_Tick is called from nearly every known updateProc (ship, generic enemy, FollowTargetPosition, directional projectile, the new periodic-spawn proc) — it's the shared "advance my sprite animation" step, not itself an object identity.
  • The $9a40 spawner and $4180 projectile are now linked by disassembly plus live capture. $9a40 reads the player coordinates at $3923c/$39240, computes an aimed direction, sets projectile type 6 and sprite $546d6, then enters the spawn path. The captured $4180 projectiles use that exact sprite and move nearly horizontally. Direct $9a40 objects with proc3=$3eec are the destructible wall-mounted shooters; their animated spriteId changes do not represent new objects.
  • A full "hit-reaction" / damage family was found, all funneling into a shared SpawnExplosionEffect_TailIntoAllocator (0x3a5e) terminal step: single-object damage-then- explode (0x6240), a compound/multi-part variant that forwards damage through the self->target(0x56)/self->0x5a child-chain and destroys every member at once (0x6160) — almost certainly the multi-hit-point model for a shared-health compound object — flash-only/indestructible (0x61fe), damage-plus-score-then-explode (0x63a8), and one-hit- kill (0x6208, confirmed installed as proc3 on all 8 "Kill the Eyes" boss segments — each segment dies in exactly one hit; beating the boss means destroying all 8). These read a caller- supplied damage amount in D0w rather than being driven by the object's own state, so they're most likely proc3 (collision-response) callbacks rather than per-frame updateProcs — not yet confirmed against a live collision-detection call site.
  • Two independent, separately-advancing RNG streams, not just one: FUN_000099fa snapshots the live RNG state before calling the periodic-spawn proc; FUN_00009a0c does a full swap-in/run/ swap-out around the same call. This is the clearest evidence yet for the §2.8 extrapolation concern — most plausibly a per-player RNG isolation mechanism (2-player alternating turns) so that the inactive player's background updates don't perturb the active player's random sequence. Confirming which context invokes which wrapper is high-priority follow-up work.
  • One correction to existing documentation: ShipUpdate_ProcessInputMovementCamera_FUN_00006734's own comment describes FUN_00006156 as "directional movement" logic. Decompiled, it's just return 0x7f; — a constant, no movement math. The real movement computation is the call to ShipMovement_ApplyJoystickAndScroll_FUN_0000661c; 0x6156 merely hands back a fixed sentinel/magnitude value. The $661c conclusion comes from its 68000 disassembly, not the decompiler.
  • Also resolved: a shared collision/interaction-box helper (FUN_00003d58, reused by ship/enemy/ FollowTargetPosition/directional projectiles, with a separately-compiled duplicate at FUN_00009abe), a ship-hitbox-vs-point collision predicate gated by the invulnerability flag (FUN_00005da2), the player-shield damage helper confirmed against the HUD shield-bar address (PlayerShield_SubtractDamage_FUN_000065a4 → $cc6), a direction/octant classifier (FUN_000039a4), a random-in-range helper built on Next_random (FUN_000028a4), a bulk "return this whole list to the free list" deallocator used at level init/reset (FUN_0000707e), and a pair of structurally-identical secondary tile/background redraw routines (0x21ac/0x2504) that mirror ST_DrawGeneratedBackgroundFromTileMap_FUN_00001dd0 but target a different destination buffer — best guess is a 2-player alternate-screen-slot tile path, unconfirmed.
  • One loose end: FUN_00003d86 (slot 25, no static callers) and FUN_00003d9c (slot 53, one real caller at 0x7dec) decompiled to byte-identical bodies — worth re-checking raw disassembly before trusting that as a genuine duplicate rather than a tooling artifact.

Net effect on the update-pass discovery question: static analysis alone, without any live capture, has now accounted for essentially the entire vector table. The remaining unknowns are narrow and specific (which context swaps in the second RNG stream; whether the hit-reaction family is proc3 or updateProc; the 0x3d86/0x3d9c duplication) rather than "undiscovered object types" — live instrumentation (§8) is now mainly useful for confirming these static findings and catching anything installed via the direct-literal-store path (formation/boss spawns) rather than for discovering entirely new procs.

2.10 Appendix — the full 56-slot table

Every slot of the JMP trampoline table starting at ScriptedSineMotionPeriodicSpawner_UpdateProc_Thunk_00000e1e (0x0e1e-0x0f6d), reproduced from the address's own Ghidra plate comment so this document is self-contained without needing Ghidra open. "Target" is the first-level jump destination; → means that target is itself a further thunk. Categories: UPDATEPROC = confirmed installed as a real per-object update proc; HIT-REACT = member of the damage/hit-reaction family (§2.9); DUP = a second trampoline to a slot already listed above it; HELPER = a shared subroutine, not an object identity; RNG = touches the pseudo-random generator; SPAWN/ALLOC = spawn-pipeline plumbing; SENTINEL/DATA/DRAWPROC = as labeled. Slots already covered in §2.2-§2.9's prose link back there rather than repeating detail.

Slot Addr Target Category Role
0 0x0e1e 0x9a40 UPDATEPROC ScriptedSineMotionPeriodicSpawner; direct first-level $9a40/$3eec objects are pickup carriers (§2.6)
1 0x0e24 0x10a2 DRAWPROC draw_ship_maybe — generic masked-sprite blitter (§2.3)
2 0x0e2a 0x6240 HIT-REACT Single-object damage + flash + explode
3 0x0e30 0x6160 HIT-REACT Compound/multi-part damage via self->0x56/0x5a chain
4 0x0e36 0x613e UPDATEPROC FollowTargetPosition (§2.6)
5 0x0e3c 0x34ea UPDATEPROC AnimationSequence_Start — loads first script frame (§2.9)
6 0x0e42 0x34fa UPDATEPROC Self/children terminal-destroy utility (§2.9)
7 0x0e48 0x27fe RNG Next_random — the core PRNG (§2.8)
8 0x0e4e 0x39fe ALLOC Allocator tail-alias (bare jump into AllocateOrRecycleObjectEntry)
9 0x0e54 0x107c UPDATEPROC DestroyObject_UnlinkAndFree — the terminal "Destroy" proc (§2.6)
10 0x0e5a 0x39a2 SENTINEL NoOpDummyHandler — "disable this handler" stub (§2.3)
11 0x0e60 0x3d58 HELPER Collision/interaction-box recompute from sprite+position
12 0x0e66 0x2be4 ALLOC AllocateOrRecycleObjectEntry itself
13 0x0e6c 0x3ac0 SPAWN SpawnInit_ConfigureNewEntry (§2.7)
14 0x0e72 0x3b18 SPAWN SpawnInit_RepeatAllocate — enemy-type spawn stub entry point (§2.7)
15 0x0e78 0x1594 DRAWPROC DrawFlashFrame_RevertToNormalDraw — one-shot flash (§2.4)
16 0x0e7e 0x2996 ALLOC Allocator tail-alias
17 0x0e84 0x4a98 ALLOC Allocator tail-alias
18 0x0e8a 0x9a44 → 0x9a40 DUP = slot 0
19 0x0e90 0x39a4 HELPER Octant/direction classifier from a (dx,dy) pair
20 0x0e96 0x61fe HIT-REACT Flash-only, no health tracking (cosmetic hit ack)
21 0x0e9c 0x21ac HELPER Secondary tile/background redraw — likely 2P-alternate-buffer path, unconfirmed
22 0x0ea2 0x10ae HELPER blitting_top_level_function — called by draw_ship_maybe
23 0x0ea8 0x2504 HELPER Twin of slot 21's secondary tile drawer; has one real caller (0x6454)
24 0x0eae 0x34c2 HELPER AnimationSequence_Tick (§2.9)
25 0x0eb4 0x3d86 HELPER Uncertain — possible level-script/checkpoint action handler; zero static callers
26 0x0eba 0x634c HIT-REACT Trivial "always explode," no damage logic — bare alias into slot 30
27 0x0ec0 0x5da2 HELPER Ship-hitbox-vs-point predicate, gated by the invulnerability flag
28 0x0ec6 0x65a4 HELPER PlayerShield_SubtractDamage — subtracts collision damage from the HUD shield meter; depletion enters the life-loss path (§2.9)
29 0x0ecc 0x1912 DATA Blank/empty sprite data (ship's invulnerability-blink frame), not code
30 0x0ed2 0x3a5e HIT-REACT SpawnExplosionEffect_TailIntoAllocator — the family's shared terminal step
31 0x0ed8 0x3af4 HELPER thunk_FUN_00003af4 — collision-scan helper, also called directly every frame from 0x7006
32 0x0ede 0x6156 HELPER Trivial return 0x7f constant — corrected this session, not "movement logic" (§2.9)
33 0x0ee4 0x28b8 HELPER Point-in-rectangle containment test
34 0x0eea 0x702c UPDATEPROC BackgroundScrollCursor_UpdatePerFrame (§3.3)
35 0x0ef0 0x2aba UPDATEPROC Starfield_UpdateVerticalPositions (§3.4)
36 0x0ef6 0x6250 HIT-REACT Trivial "always explode," no damage logic — bare alias into slot 30
37 0x0efc 0x99fa RNG Snapshot wrapper around the periodic-spawn updateProc (§2.9)
38 0x0f02 0x9a0c RNG Full swap-in/run/swap-out wrapper — the per-player RNG isolation evidence (§2.9)
39 0x0f08 0x9abe HELPER Duplicate compiled instance of slot 11's box calc, wraps a call to slot 50
40 0x0f0e 0x4180 UPDATEPROC Horizontal damaging projectile trajectory (§2.6)
41 0x0f14 0x707e HELPER FreeEntireObjectList_ReturnToFreeList — bulk deallocator, level init/reset
42 0x0f1a 0x159c → 0x1594 DUP = slot 15
43 0x0f20 0x5d86 HELPER Invulnerability gate wrapping slot 33's point-in-rect test
44 0x0f26 0x15a8 → 0x1594 DUP = slot 15
45 0x0f2c 0x28a4 RNG Random-in-range helper built on slot 7
46 0x0f32 0x63a8 HIT-REACT Damage + award score (self->0x42 → DAT_00000c92) + explode
47 0x0f38 0x6208 HIT-REACT One-hit-kill — flash then unconditional destroy, no health countdown
48 0x0f3e 0x6210 → 0x6208 DUP = slot 47 — installed as proc3 on all 8 "Kill the Eyes" boss segments (§2.9, §10 correction)
49 0x0f44 0x4020 UPDATEPROC Silent pair/group despawn, no explosion/score (§2.9)
50 0x0f4a 0x9aca HELPER Scripted 16.16 sine-table position core; consumes object $28/$2A/$4A/$4E/$52/$54
51 0x0f50 0x4ea0 UPDATEPROC CompassOrbitThenDropCollision_Update — shared generic enemy/released-pickup movement (§2.6)
52 0x0f56 0x5fec UPDATEPROC Minimal "animate only" updateProc — calls only AnimationSequence_Tick (§2.9)
53 0x0f5c 0x3d9c HELPER Byte-identical body to slot 25, but has one real caller (0x7dec) — see §2.9's loose end
54 0x0f62 0x3cee → 0x2be4 DUP = slot 12 (double thunk)
55 0x0f68 0x3ccc → 0x2be4 DUP = slot 12 (double thunk)

Tally (counted directly from the table above): 11 UPDATEPROC, 17 HELPER, 8 HIT-REACT, 6 DUP, 4 RNG, 4 ALLOC, 2 SPAWN, 2 DRAWPROC, 1 SENTINEL, 1 DATA = 56. Of the 11 UPDATEPROC rows, only 9 (slots 0, 4, 5, 6, 9, 18, 40, 49, 52) are the "invisible to Ghidra" kind called out in §2.9 — reachable only through this vector table, with no other static call-graph edge anywhere in the binary. The other 2 (slots 34, 35 — the background-scroll and starfield per-frame routines) and slot 51 (generic enemy) are genuine updateProcs too, but were already known from ordinary call-graph analysis before this table was ever swept, so §2.9's "~9" headline figure doesn't count them. Formation/boss-specific and enemy-type-specific updateProcs (FormationLeaderUpdate_ScrollLocked, FormationFollowerUpdate_ChaseNextInChain, BossSegmentUpdate_ChaseAnchorOrPredecessor, RadialBurstProjectile_Update, BossSegmentedSpawn_Init) are not in this table at all — they're installed directly via literal move.l #addr,(0x2/0x6/0xa,An), bypassing the vector table entirely (§2.6).

2.11 $9aca scripted sine-table movement

The $e1e first-level swarms reach $9aca through $9a40 -> $9abe. Assembly, not decompiler inference, establishes the state layout:

  • $20/$24: signed 16.16 object->x/object->y (the low y word is object->typeState.scriptedSineMotion.ySubpixelOrFraction);
  • $28: object->typeState.scriptedSineMotion.remainingSubsteps;
  • $2a: object->typeState.scriptedSineMotion.phase (low byte indexes the signed sine table at $9c80; x uses the same table shifted by 64 entries);
  • $4a: object->typeState.scriptedSineMotion.scriptCursor;
  • $4e/$52: .phaseDelta and .phaseAcceleration;
  • $54: .substepsPerFrame, executed per canonical game frame.

When $28 expires before the frame's $54 budget, $9aca immediately dispatches the command at $4a and continues the unused substeps in the same frame. Opcode 2 lands at $9c18 and consumes ten bytes: {word opcode, word initialPhase, word phaseDelta, word phaseAcceleration, word duration}. It advances $4a by ten.

Automation observations retain the original 98-byte entry and append 128 bytes copied from scriptStart at $46 for verified scripted-motion update procedures. Anchoring the capture at the start preserves opcode-8 backward loops; the current Python predictor executes the deterministic command stream and reports integer high-word anchor transitions. See SCRIPT.MD for all six opcodes, exact instruction sizes, timing, capture format, and validation method. xenon_tools/xenon_symbols.py mirrors the XENON_* address and offset names used by src/xenonControl.c; ReVa uses the same terminology for functions and fields.

3. Sprite / graphics formats

Multiple distinct on-disk pixel formats exist in this binary; they're not interchangeable and each needed separate reverse-engineering. See src/xenonRender.c's SpriteFormat enum for the canonical list; summary of the ones fully understood:

3.1 SPRITE_FORMAT_4PLANES_MASKED — standard object sprites

Self-describing header (SpriteData): x_origin(i16) y_origin(i16) width_info(i16) height_info(i16), then one mask word + color data per row. Decoded by DrawSTMaskedSprite16or32_ARGB8888_private in xenonRender.c. This is what draw_ship_maybe_FUN_000010a2 draws.

3.2 Wall tiles — SPRITE_FORMAT_4PLANES_TILE (opaque) / _TILE_MASKED

Fixed-size 16x16 tiles, no self-describing header — just raw packed pixel data, addressed directly by a numeric index. Drawn by ST_DrawGeneratedBackgroundFromTileMap_FUN_00001dd0 (0x1dd0-0x21ab), which redraws the whole visible tile grid every frame across three row-bands (top partial / 11 full middle rows / bottom partial — see below), with the masked/unmasked/empty dispatch fully inlined and triplicated (no shared per-tile subroutine to hook).

Per-cell dispatch, on the raw tilemap code word: - code == 0: "empty" — filled from the background mosaic instead (§3.3), not an individual tile. - code < 0 (bit15 set): masked tile. index = code & 0x7fff. 12 bytes/row (AND-long, OR-long, direct-long triplet): out = orWord | (bgWord & andWord). - code > 0: opaque tile. index = code. 8 bytes/row, straight copy.

Address formula (confirmed via raw disassembly — andi.w #0x7fff,D0w; lsl.l #0x4,D0, a genuine multiply-by-16, not a table lookup):

source = tileGraphicsBase + N*scrollVariant + index*0x10

where tileGraphicsBase = live value of PTR_DAT_0004f008 (confirmed = 0x00059c42), scrollVariant = live fine-scroll fraction (0x00000cd6, 0-15, pre-shifted source copies to avoid runtime bit-shifting — same technique used for bullets, §3.5), and N = 2 (opaque) or 3 (masked).

Important gotcha, resolved this session: raw tilemap code 0 is reserved ("empty"), never a real tile index — confirmed by cross-referencing every tile index actually placed in a live level's tilemap (minimum observed index is 1). An earlier offline reconstruction tool (RenderUsedMaskedAndUnmaskedWallTiles in xenonRender.c) assumed indices started at 0 and compensated with an unexplained +16 fudge factor in its own address math — the real cause was this off-by-one, not a genuine formula offset. Both that function and the live capture hook now start their walks at index 1 with no +16.

Row-bands (each independently unrolled, own fetch PC): | Band | Row count | Destination Y | Fetch PC | |---|---|---|---| | Top (partial) | 16 - fineScroll | Always pinned to screen y=0, regardless of scroll state — the partial-reveal illusion is entirely row-count, not position | 0x1ea8 (state committed at 0x1eaa) | | Middle (full) | 11 tiles × 16 rows | Genuinely scrolls frame to frame | 0x1f2a (committed at 0x1f2c) | | Bottom (partial) | fineScroll (band entirely skipped when fineScroll==0) | Continues naturally from the middle band | 0x214e (committed at 0x2150) |

Top-drawn-rows + bottom-drawn-rows always sum to exactly 16 (one shared tile-row's worth of vertical space, split between the two edges as scrolling progresses). fineScroll = live value of 0x00000cd6 (word). 0x00000cce holds the coarse byte offset into the level's tilemap array, PTR_DAT_0004f004_tileMapBase; 0x00000ccc is the default camera scroll delta. The applied delta is stored at 0x00000cd8 after camera limits are enforced. Rendering captures these values with the draw frame.

The wall-tile layer's total drawable height (176 middle + 16 top/bottom) is 192 lines — this number recurs, see §3.3.

3.3 Background mosaic — SPRITE_FORMAT_2PLANES (parallax backdrop)

A single pre-rendered mosaic bitmap (2 bitplanes), scrolled as a whole rather than authored per-tile like walls. Filled in for every "empty" (code==0) tilemap cell.

  • Base: read live from PTR_0004f000_points_to_Background (0x4F000). Level 1 uses 0x0006989c, while later level arenas relocate the mosaic (level 2 uses 0x0006ce6c), so it must not be treated as a fixed address. The pointer is set at level initialization, and each per-level runtime atlas contains a background record keyed by that level's pointer value.
  • Live scroll cursor: PTR_DAT_0436_currentTileMapPointer (ST address 0x436) — what the draw code actually reads from each frame.
  • Cursor update: BackgroundScrollCursor_UpdatePerFrame_FUN_0000702c (0x702c), called once per frame before tile/background drawing. The caller at 0x7a7a loads the applied camera delta (0xcd8) into D0. The background uses half that rate: (delta >> 1) + (delta & gameClock & 1), where the game clock is at 0xcdc. Clock parity alternates integer steps to represent half-pixel movement. Thus the background has a separate cursor and slower parallax, but shares the camera's applied movement. Earlier notes describing independent speed registers at 0xcda/0xcdc were incorrect; these are requested delta/game clock.
  • Wrap: the cursor is kept wrapped within [base, base+0x300) = 768 bytes = 192 scanlines (4 bytes/scanline for this 2-bitplane format) by matching wrap-correction logic in both the incrementing and decrementing directions (the game supports genuine reverse/backward vertical scroll, not just forward — both are live in normal play).
  • 192 is not a coincidence — it exactly matches the wall-tile layer's own total per-frame row coverage (§3.2). Both layers are built around the same 192-line playfield; the real 200-line ST screen has ~8 lines reserved for something else (a status bar, per visual confirmation).
  • No horizontal scroll observed — the mosaic is always left-aligned at screen x=0, spanning the full 320px width; only vertical position varies.

Live scroll position within the mosaic: scrollY = ((cursor - backgroundBase) / 4) % 192, where backgroundBase = read_long(0x4F000).

3.4 Starfield — 48 depth-sorted single-pixel stars

The starfield is a separate direct-framebuffer effect rather than an object list or sprite format. Starfield_UpdateAndDraw_48Stars (0x2b1a) is called once per displayed game frame at main-loop PC 0x7a86, immediately after ST_DrawGeneratedBackgroundFromTileMap_FUN_00001dd0 returns at 0x7a82 and before the object pass begins at 0x7a9c. It first calls Starfield_UpdateVerticalPositions (0x2aba), then plots the updated stars.

3.4.1 State and initialization

There are exactly 48 stars, stored as consecutive 6-byte records beginning at Starfield_48StarRecords (0x3d1dc):

struct StarRecord {
    uint16_t screenByteOffset;     /* within the 320x192, 4-plane framebuffer */
    int16_t  verticalSubpixelPhase;
    uint16_t xBitMask;             /* exactly one of the 16 bits is set */
};

Starfield_InitializeRandomPositions (0x7f0a) initializes all 48 records. It chooses a random 8-byte-aligned framebuffer offset below 0x7800 and a random one-bit X mask, then clears the subpixel phase. 0x7800 = 192 * 160, so initialization and runtime wrapping both cover the same 320x192 playfield used by the wall and mosaic layers. The byte offset selects a scanline and one of its twenty 16-pixel groups; the bit mask selects the exact pixel within that group.

3.4.2 Vertical parallax

The update routine reads the signed live camera/wall scroll speed from word 0x0cd8. A signed lookup table at 0x2a98 converts speed n to n * 0x180 subpixel units. For star index i (0..47), another i * n * 8 units are added, giving the exact velocity:

verticalSubpixelDelta(i) = n * (0x180 + 8*i)
verticalSpeed(i)         = n * (1 + i/48) scanlines per frame

There are therefore 48 progressively different speeds, not merely four discrete parallax speeds. The first star moves at 1.0 * n; the last moves at 95/48 * n (about 1.979 * n). Signed n makes the same code work in both scrolling directions.

The phase wraps modulo 0x180 subpixel units. Each crossing adjusts screenByteOffset by +0xa0 or -0xa0 bytes (one 160-byte scanline), and the byte offset itself wraps modulo 0x7800, keeping every star within the 192-line playfield without changing its X position.

This per-frame dithered stepping (visible Y changes 0 or 1 scanline per frame, never smoothly) is the confirmed root cause of a known, deliberately-deferred frame-extrapolation limitation — see §8's known-limitations list, "vertical-scroll starfield extrapolation still visibly 'snaps back' a little."

3.4.3 Brightness/depth bands

Drawing is unrolled into four groups of 12 stars. Each group writes a different combination of ST bitplanes, producing palette indices 4 through 7:

Star indices Written planes Palette index Relative speed range
0-11 plane 2 4 1.000*n to 1.229*n
12-23 planes 0+2 5 1.250*n to 1.479*n
24-35 planes 1+2 6 1.500*n to 1.729*n
36-47 planes 0+1+2 7 1.750*n to 1.979*n

This directly couples apparent depth, brightness, and motion: later/faster stars use higher palette indices. Whether indices 4-7 are perceptually monotonic depends on the live level palette, but the renderer's bitplane choices and speed ordering are fixed as shown above.

3.4.4 Compositing and occlusion

Before plotting a star, the routine ORs together the destination words from all four bitplanes and tests the star's one-bit mask against that result. It writes the star only when that pixel is zero in every plane. Consequently stars appear only on color-index-0 pixels and never overwrite a nonblack wall or background-mosaic pixel. The star renderer does not clear or mask the destination first; it only ORs the appropriate color planes for a successful pixel.

Objects are rendered afterward, so their ordinary masked drawing can cover stars. This explains the complete visual layering without treating stars as object-list entries:

background mosaic / wall tiles -> stars on remaining black pixels -> ship/enemies/bullets

3.5 Bullets — SPRITE_FORMAT_BULLET_PRESHIFTED16

Drawn by DrawPreshiftedBulletObject_FUN_00001cd6 (0x1cd6) — a routine unrelated to the standard masked-sprite blitter, discovered via live drawProc capture. 38-byte header: x_origin(i16) y_origin(i16) rowCount(i16, real height = rowCount+1), then a 16-entry int16 alignmentOffset[] table, indexed by screenX & 0xF — each entry is a byte offset (relative to the header) to one of 16 pre-baked, pre-shifted copies of the sprite's row data, avoiding runtime bit-shifting in the original CPU blitter.

Offset sign selects two different physical encodings: - offset >= 0 ("narrow"): address = headerBase + offset. 16px wide, 4 bytes/row (2 words). - offset < 0 ("wide"): address = headerBase - offset (still resolves forward, never before the header — subtracting a negative adds its magnitude). 32px wide, 8 bytes/row (2 longwords) — because at this alignment the shifted sprite straddles a 16px destination-word boundary.

Per-pixel compositing (both encodings): mask = wordA | wordB (0 = transparent); colorIndex = 4 | (wordA_bit?1:0) | (wordB_bit?2:0) — only 2 real bitplanes stored; plane2 always hardcoded 1, plane3 always hardcoded 0 wherever opaque, so only color indices 4-7 ever appear.

Production/atlas rendering only ever decodes variant 0 (index 0 of the 16) as the canonical image — a GPU doesn't need the shift-avoidance trick, so the other 15 are redundant for display (still relevant for highlighting/region-scanning, since all 16 physical byte ranges fold onto the same canonical display pixels).

At $1CD6, exact disassembly gives the same placement used by Sprite Stream's object-dispatch capture: x = object.x - sprite.x_origin, y = object.y - sprite.y_origin; X is then word-aligned and alignmentOffset[x & 15] selects the pre-shifted source. A live A/B test between dispatcher capture and an additional $1CD6 entry hook produced identical command streams: every observed bullet invocation already arrived through the dispatcher. Apparent missing 3×3 weapon pixels in paired AVI comparisons were positions from the authentic renderer's previous displayed game frame, not evidence of an uncaptured $1CD6 invocation.

The bullet graphics blob (0x0E2E8-0x10527 in the captured snapshot) is itself a sequence of variable-size (128 or 192-byte-ish, format-dependent) sprites packed back-to-back with an 8-byte trailing gap between them — discovered by walking consecutive real captured bullet addresses and finding their deltas always land on exact +8 boundaries past each sprite's own computed size.

3.6 Zoom-text interstitial screens — scaled 16×22 font + perspective starfield flythrough

Not part of the gameplay object/tile/starfield pipeline at all — a separate full-screen effect used for "GET READY PLAYER n", the "HIGH SCORE / LOADING LEVEL n" transition, and evidently other interstitials sharing the same machinery (all the driver functions below have multiple callers beyond the two confirmed here). Triggered on life loss via OnLifeLost_SwapTurnAndRespawn_FUN_000076b4 (0x76b4), which does the turn-swap housekeeping already documented in §2 (FUN_0000765a_maybe_swap_tile_map) and then calls SetupRespawnAndShowGetReady_FUN_00007586 (0x7586) to reset the ship's spawn position and drive ZoomTextInterstitial_ShowScreen_FUN_000084c4 (0x84c4).

This is a genuinely different rendering mode from normal gameplay in two ways: the text is a large scaled bitmap font drawn nowhere else in the game, and the starfield becomes a perspective fly-through instead of the vertical-parallax scroll of §3.4. Both differences are detailed below.

3.6.1 The scaled 16×22 font

blitting_DrawFixedLengthText16x22 (0x8004) already carries a precise plate comment in the Ghidra project, reproduced here:

Input string:  A1
Destination:   A0
Length:        D7 + 1 characters
Font table:    $9DC0
Glyph lookup:  FUN_00007FD8 using table at $871A
Glyph size:    16 × 22 pixels
Glyph format:  Atari ST low-res, 4 planar bitplanes
Glyph bytes:   176 bytes each

0x8004 is not actually used by the zoom-text interstitial screens — confirmed live (ReVa, get-decompilation against /mydumpat0): it has exactly one caller in the whole binary (FUN_00009204), a single-digit HUD-style blit elsewhere in the game, always called with lengthMinus1=0 (one character). It never reads DAT_00007f3c and always draws native-size, fixed 16px-per-character advance. The interstitial screens' actual text blitter is blitter_drawScaledText_FUN_0000803a (0x803a), called from ZoomTextInterstitial_MainLoop_FUN_00008530 (0x8530) — a separate function, not a thunk or wrapper around 0x8004, sharing its A0=destination/A1=source-string convention and the same FUN_00007FD8 charset lookup, but not its D7=char-count convention — see below.

0x803a itself is shared by three unrelated screens, not just the interstitial — confirmed live via find-cross-references/get-decompilation: besides 0x8530 (return address 0x859a after its bsr.w, confirmed 4 bytes via a raw read-memory of the opcode), 0x803a is also called from TitleScreen_CreditsScrollDriver_FUN_000082d2 (the scrolling end-credits screen, " DESIGNED BY THE BITMAP BROTHERS..." at 0x878e — reassigns the same shared "text" pointer slot PTR_s_GET_READY_PLAYER_00007f38 that 0x8530 uses, to a completely different string, and draws it at a fixed DAT_00007f3e = 0x78 via its own per-frame loop) and from HighScoreScreen_DrawTitleAndTable_FUN_00008b40 (a fixed 10-row high-score-table listing, always forcing DAT_00007f3c = 0x10 i.e. unscaled, called from FUN_000089fc alongside — not instead of — 0x8530). A capture hook keyed purely on PC=0x803a therefore captures all three indiscriminately, which was confirmed live to be the cause of a real symptom: two overlapping copies of text on screen at once (the credits screen's fixed-Y draw plus the interstitial's real animated one) with readable garbling where they overlapped. DrawCommandStream_OnZoomTextGlyphInstructionFetch (src/drawCommandStream.c) now gates on the return address on the stack at hook-fire time (already pushed by the caller's bsr before control reaches 0x803a), accepting only 0x859a.

0x803a ignores whatever D7 holds at entry — it always draws a fixed 20-character field, regardless of the caller. Ghidra's decompiler infers a textLengthMinus1 parameter for 0x803a (a false positive from its "read before write on some path" heuristic — the caller's ZoomTextInterstitial_MainLoop_FUN_00008530 never actually sets D7 before the call either, it's leftover from an unrelated per-frame movem.l palette-table load). The real behavior, confirmed via two independent live read-memory calls against /mydumpat0: the raw bytes 7E 13 (moveq #0x13,D7) sit at both 0x8002 (start of the unscaled path) and 0x8074 (inside the scaled path), unconditionally executed before either path's char loop begins — so every string 0x803a draws is exactly 20 characters (0x13 + 1, dbf semantics), independent of the caller. A first live capture attempt that read D7 at 0x803a's own entry PC (before this internal reload executes) observed D7 = 0x0758FFFF — clearly not a valid length — which is exactly why an earlier version of this capture never rendered any interstitial text at all: it rejected the call as implausible. DrawCommandStream_OnZoomTextGlyphInstructionFetch (src/drawCommandStream.c) now hardcodes 20 characters for 0x803a instead of reading D7, matching the game's own behavior.

The fixed 20-byte field is not specific to "GET READY PLAYER n" — it's how every string passed to 0x803a is formatted, confirmed against two independent real strings in the game's own data (get-strings/read-memory against /mydumpat0, not inferred): the "GET READY PLAYER" string at 0x887e is " GET READY PLAYER " (18 bytes) followed immediately by a single byte at 0x8890 that SetupRespawnAndShowGetReady_FUN_00007586 patches with the player-number digit — 18 + 1 = 19 real characters, and the live byte at offset 19 (0x8891) is a space, giving exactly 20 bytes end to end. Separately, the string at 0x8c32, " HIGH SCORE LOADING LEVEL 1 " (41 bytes), is exactly two adjacent 20-byte fields end to end (" HIGH SCORE " + " LOADING LEVEL 1 ", byte 40 unused/overrun) — consistent with two separate 0x803a calls (one per line) rather than one longer draw. No string found in the binary suggests a literal "GAME OVER" screen using this same driver, so that specific case wasn't directly confirmed, but since the 20-byte width comes from 0x803a's own hardcoded moveq #0x13,D7 — not from anything caller- or message-specific — it applies uniformly to any string this function draws.

Strings are neither NUL-terminated nor length-prefixed — 0x803a always reads exactly 20 bytes from A1 regardless of content; there is no length field anywhere in its calling convention and no early-exit on a NUL byte. Unused tail positions are simply padded with literal space characters (0x20) in the real string data above. Spaces render as blank not because of any special case for whitespace, but because the charset "!ABCDEFGHIJKLMNOPQRSTUVWXYZ.:><0123456789+" simply doesn't contain one, and any character FUN_00007FD8 can't find is silently skipped.

Correction to a Ghidra decompiler mislabel: the decompiled pseudocode for 0x803a names a variable bVar19 = *in_A1 < '\0' and gates the glyph draw on if (!bVar19), which reads as if the skip condition were the raw input character's sign bit. Disassembling FUN_00007FD8 byte-for-byte (read-memory, /mydumpat0) shows this is wrong: the function returns the matched character's 0-based charset index in D1 on a match (D1 = finalA2 - originalA2 - 1, restoring A2 via a saved/popped stack slot), or explicitly moveq #-1,D1 if it falls through to the charset string's own NUL terminator without a match — and the bmi.w branch right after 0x803a's bsr.w 0x00007fd8 tests that returned index's sign, not the original character. So the real rule is: any character absent from the charset is skipped, full stop — matching DrawCommandStream_CaptureZoomText16x22's existing ZoomTextCharsetLookup behavior exactly (return -1 → skip).

176 = 8 bytes/row × 22 rows — 2 bytes/plane × 4 planes per row, i.e. the same unmasked format already documented for wall tiles (SPRITE_FORMAT_4PLANES_TILE, §3.2), just a much larger glyph. Confirmed live: 0x803a computes each glyph's source address as &DAT_00009dc0 + glyphIndex*0xb0 (0xb0 = 176) — matches exactly, provided glyphIndex is 0-based from 0x871A, not 0x8719 — see the correction below.

Correction: the charset scan table is 41 characters starting at 0x871A, not the 42-character string at 0x8719. The string found by get-strings at 0x8719 is "!ABCDEFGHIJKLMNOPQRSTUVWXYZ. :><0123456789+" (42 chars including the leading !) — but the original plate comment on blitting_DrawFixedLengthText16x22 (0x8004, reproduced at the top of this section) documents the lookup table address as $871A, one byte later, and this got misread when the font-capture region was first implemented (0x8719 used instead). 0x871A skips the leading !, meaning FUN_00007FD8 actually scans "ABCDEFGHIJKLMNOPQRSTUVWXYZ.:><0123456789+" (41 chars, 'A' = index 0) — and the font table's slot 0 (0x9DC0) is 'A', not '!'; there is no reachable glyph for ! via this font. Confirmed live: read-memory at 0x9DC0 shows two dense, full-width rows (0x3FF0, 0x7FF8 — bits spread across most of the 16px row), inconsistent with !'s narrow single-stroke-plus-dot shape. This was also confirmed by the visible symptom before the fix: every rendered character was consistently one charset position ahead of the intended one (G→H, E→F, T→U, ... and the trailing player-number digit '1'→'2') — a uniform off-by-one matching exactly what an incorrect charset base predicts. The font table therefore spans 0x9DC0-0xBA10 (41 × 176 = 7216 = 0x1C30 bytes), not 0x9DC0-0xBAA0.

0x803a loops over the whole string internally (a dbf D7w loop wrapping the per-character charset-lookup-and-blit sequence, confirmed live) — it is not a per-glyph primitive invoked in a loop by its caller, so there is no per-glyph call site to hook; a capture pipeline needs to walk the string itself (see DrawCommandStream_OnZoomTextGlyphInstructionFetch, src/drawCommandStream.c).

Scaling mechanism — confirmed live, and it's not purely vertical. 0x803a branches on DAT_00007f3c (cmpi.w #0x10,(0x00007f3c).w; bge.b <unscaled path>): values 0-15 take a scaled path, >=16 take a straight unscaled 1:1 copy (always 22 rows tall, 16px advance/char, no doubling). DAT_00007f3c is read once per animation frame from a "zoom script" table pointed to by the caller's A1 at 0x8530 entry, terminated by a negative entry (per ZoomTextInterstitial_ShowScreen_FUN_000084c4's own Ghidra plate comment) — it is authored animation data, not a computed ramp, so there is no formula for its value over time; only the mapping from a given value to on-screen glyph size is derived below.

The script has one semantic stop value. Disassembly at $8552..$856C shows that when ZoomTextInterstitial_WaitForFireMode_000084c2 is nonzero and the signed word at ZoomTextInterstitial_ScriptPointer_000084be + ZoomTextInterstitial_ScriptOffset_00007f42 equals $0011, the cursor is deliberately held. A rising joystick-fire edge sets InterstitialFireButtonEdgeLatch_00000a01; $8568 clears that latch and $856C advances the cursor. This is the GET READY wait used after a level load. It explains why a restored transition can show no objects indefinitely even though canonical frames continue. Protocol v7 exports the exact predicate, current script word/offset, and latch rather than approximating it with elapsed frames or stale shop state.

For the scaled path (0-15), the earlier draft of this section described the effect as "vertical scale instead of horizontal shift" — confirmed correct on closer inspection, but for a subtler reason than originally stated:

  • Vertical (genuine scale): a 16-entry uint16 row-doubling mask table at 0x876c, indexed directly by DAT_00007f3c. Read live via ReVa (read-memory): the 16 values are 0000 0100 1010 2104 4444 4912 5252 552A AAAA AB55 B5B5 B76D DDDD DF7B F7F7 FF7F, and popcount(table[i]) == i exactly for i = 0..15 — a deliberately-authored progression, not arbitrary bit patterns. The glyph's row loop always runs 22 iterations (matching the native 22-row glyph height); each iteration tests the mask's bit 15, rotates the mask left by one bit (rol.w #1), and if that tested bit was set, the current source row's blit additionally advances the destination by one extra scanline (visually: that row's content ends up occupying two destination scanlines instead of one). Simulating that exact 22-iteration rotate/count against the live table gives on-screen glyph heights of 22, 23, 25, 26, 28, 29, 30, 32, 33, 34, 36, 37, 38, 40, 41, 43 px for scale 0 through 15 respectively (22 + i + popcount((table[i]>>10)&0x3F), though the implementation replicates the rotate loop directly rather than trusting that closed form). >=16 (unscaled) is flat 22px.
  • Horizontal (sub-pixel placement, not stretch): DAT_00007f3c is also added directly to a per-character sub-pixel accumulator (sVar += DAT_00007f3c; if (sVar > 15) sVar -= 16), whose remainder drives a rotate-and-OR alignment of the source pixel words into the destination — this is the exact same "coverage pattern" technique already documented for bullets' sub-pixel horizontal shift (§3.5), reused here unmodified, not adapted into a stretch. So the practical effect is that DAT_00007f3c (0-15, or 16 when unscaled) is the true on-screen pixel advance between successive characters' origins — letters pack tighter (and their columns literally OR-overlap on real hardware) as the scale value shrinks, rather than each glyph's own pixel content being stretched narrower. A capture/replay pipeline that can't reproduce an OR-blend compositing mode (e.g. a textured GPU quad renderer) has to approximate this — see the implementation note in drawCommandStream.h.

Now captured. g_spriteMemoryRegion[] (src/xenonRender.c) has a SPRITE_REGION_TYPE_ZOOMTEXT_FONT entry at 0x9DC0 (.maxSpriteCount = 41, reusing the existing DrawSTScreenBlockUnmasked_ARGB8888 tile decoder rather than a bespoke one, since the byte format is identical). DrawCommandStream_OnZoomTextGlyphInstructionFetch (src/drawCommandStream.c) hooks 0x8004's entry point and 0x8074 (not 0x803a's own entry — see below), walks the string, and pushes one masked-sprite entry per glyph with a live-computed on-screen size (native for 0x8004; scale-derived per the formulas above for 0x803a) — see DrawMaskedSpriteEntry.destW/destH (src/includes/drawCommandStream.h) for how the destination quad size is kept independent of the atlas source rect so the GPU does the stretching.

Correction: 0x803a's own entry point is the wrong place to read A0. Confirmed live: A0 does not hold the real destination address at 0x803a's first instruction. 0x803a's scaled branch does movea.l (0x0406).w,A0 at offset 0x8042 — a fresh reload from the live draw-buffer-base register — followed by a computation involving DAT_00007f3e (the Y value the caller computes right before the call) that finishes just before moveq #0x13,D7 at 0x8074. A capture hook at 0x803a's own entry reads A0 before any of this runs — i.e. garbage left over from whatever the CPU last did with that register, not the text's real position. This was confirmed live to be the actual cause of two separately-reported symptoms: wrong (top-left) positioning while the text is small during zoom, and — once thought to be a buffer-alternation issue, that theory was disproved by directly inspecting both ST screen buffers via Hatari's VRAM trace windows, which showed the correct text in both — two visible copies of the same string once the zoom animation settles. The second part of that mystery resolved once the position was fixed: 0x803a's unscaled branch (taken once DAT_00007f3c >= 16, exactly the "settled" moment) lives at a separate address (0x7ff2) and, after its own movea.l+Y-offset setup, falls through directly into blitting_DrawFixedLengthText16x22's own code — its moveq #0x13,D7 sits at 0x8002, two bytes before 0x8004's own label. So the existing 0x8004 hook was already correctly capturing the settled-phase draw; once the entry-point hook stopped feeding a second, stale-position capture for the same draw, there was no more duplicate. 0x8074 is a safe one-shot hook point (the loop-count initialization, executed exactly once per string draw, not once per glyph), and the stack pointer is unchanged from 0x803a's own entry (nothing pushes to the stack in between), so the caller-return-address gate above still applies correctly from there.

Correction (superseding the above): position for the scaled path is computed directly from $7f3c/$7f3e, not read from A0 at all. An external review of the 0x8074 fix pointed out that reading A0 — even after 0x803a's own setup completes — only recovers the word-aligned version of the destination, discarding the sub-pixel phase the CPU's rotate/OR technique otherwise uses; it also flagged a second, independent bug (below) in the height formula. Re-deriving the setup sequence instruction-by-instruction (read-memory against /mydumpat0, cross-checked against the review's own formula) confirms exactly:

D1 = centerY ($7f3e); D1 = D1 + D1; D1 = D1 - scale ($7f3c); D1 = D1 >> 1 (unsigned)
  => y = (centerY*2 - scale) >> 1

D0 = scale; D0 = D0 * 10; D1 = 160; D1 = D1 - D0; D0 = D1; D0 = D0 & 0x0F (xPhase)
D1 = D1 - D0 (xAligned = idealX & ~15); D1 = D1 >> 1 (byte offset); A0 += D1
  => idealX = 160 - 10*scale, word-aligned to xAligned for the CPU's own byte-addressable blit

idealX (not xAligned) is used directly as the capture's X position — a GPU quad isn't constrained to 16px alignment the way the CPU's own blit is, so the unaligned value is the more faithful representation, matching the review's own recommendation. For the unscaled path (reached via 0x8004, confirmed via the same disassembly-level review): x = 0 (twenty 16px characters exactly fill the 320px row width, no centering needed) and y = centerY - 8 — both already correctly captured by the existing 0x8004-reading code (see 0x8004's own movea.l+Y-offset setup above), so no change was needed for that path.

Second correction: the scaled-height formula was backwards. The same review caught that ZoomTextComputeGlyphSize's row loop was computing 22 + emittedRows where it should have been computing emittedRows alone. The row loop's own control flow (already correctly understood earlier in this section) only writes a destination row — advancing the destination pointer — when the tested mask bit is set; a row whose bit is clear is read from the source but never written. So the emitted height is the count of set bits encountered across the 22 rotations, not that count added on top of the native 22 — meaning height shrinks toward 0px as scale approaches 0 (matching the visual "zoom in from nothing" effect), not grows past 22px. At scale=15 the corrected formula gives ~21px (matching the review's own estimate); the previous, backwards formula gave ~43px.

3.6.2 Perspective starfield flythrough — reuses the gameplay star records, different math entirely

Starfield_DrawPerspectiveFlythrough_FUN_000085b4 (0x85b4) is called once per interstitial frame instead of Starfield_UpdateAndDraw_48Stars. It walks the same 48-record Starfield_48StarRecords array (0x3d1dc) used by the gameplay starfield, but reinterprets the 6-byte record as 3D (x, y, z) instead of (screenByteOffset, verticalSubpixelPhase, xBitMask), and projects it:

screenX = ((x << 4) / z) + 0xa0   // 0xa0 = 160, screen-center X
screenY = ((y << 4) / z) + 100    // screen-center Y

z decreases every call (the camera is flying forward); whenever a star's z leaves its valid band or its projected X/Y falls outside the visible 320×200 area, that record is respawned at a fresh random (x, y) with z reset far away (0x1fff) via Next_random_FUN_000027fe — this is what produces the classic "stars emerge from a vanishing point and fly past the camera" look, and is why it reads as flying into the field rather than scrolling past it.

Compositing uses the same "only plot on an all-black destination pixel" occlusion rule as §3.4.4, but the bitplane pattern comes from an 8-entry depth table at 0x867c (indexed by z >> 10 & 7) instead of unrolled index groups. Its observed bytes are 07 07 08 08 06 05 04 09 — palette indices {7, 7, 8, 8, 6, 5, 4, 9}, a wider spread than the gameplay starfield's fixed 4-7 (note index 8/9 use plane 3, never touched by the normal starfield or bullets).

Why it looks "colorful": ZoomTextInterstitial_MainLoop_FUN_00008530 (0x8530), the per-frame driver that calls both the text blitter and the star flythrough, ends every iteration by copying a color table at 0x86fa directly into the live ST palette hardware registers starting at $FFFF8240.

Correction (follow-up session): this table is 16 words (32 bytes), covering all 16 palette registers, not 8 words covering only the even-indexed half as originally stated here. The original finding was based on the decompiled pseudocode's _DAT_ffff8240 = DAT_000086fa; _DAT_ffff8244 = DAT_000086fe; ... — eight separate-looking assignments, each 4 apart, which reads as "every other word register." Re-checked against the raw instructions directly (not the decompilation) this session: both callers actually execute

movem.l (0x000086fa).l,D0-D7      ; loads 8 LONGWORDS (32 bytes) from the table
movem.l D0-D7,(0x00008240).w      ; stores those same 8 longs to the palette base

A longword store to this 16-bit-register-wide hardware bank writes two adjacent word registers per instruction (big-endian: each D-register's high word → register N, low word → register N+1), so all 16 registers get overwritten, not 8. Confirmed independently by address arithmetic: 0x86FA + 32 bytes = 0x8719, exactly one byte before the already-documented zoom-text charset table at 0x871A (§3.6.1) — a clean zero-gap boundary, the same style of check that caught every other data-table-size question this session, whereas the old (wrong) 16-byte assumption left an unexplained 16-byte gap before 0x871A that was never resolved (that gap was this bug, just manifesting as a documentation loose end instead of a visible symptom until a live capture of this range actually shipped — see §6.2/§8's "Live symptom" note).

Combined with the wider set of bitplane/palette-index combinations above (§3.6.2's starfield depth table), this is consistent with a genuinely rich, non-gameplay color scheme for this screen. Whether it's also a genuine per-frame animation (i.e. whether the bytes at 0x86FA themselves change between frames, given the table's address never changes and 0x8530 re-copies it every frame regardless) is still not confirmed either way — no mechanism that would modify 0x86FA's own contents over time has been located in either session. The claim that "both the big letters and the flythrough stars visibly shift color while on screen" was reported as a live observation by the previous session, but the why (i.e. whether that came from this palette table changing, from the perspective starfield's own depth-based palette-index spread creating a shimmering look as stars move through depth bands, or from something else) was not independently re-verified this session — worth re-checking live before relying on it. ZoomTextInterstitial_SavePaletteAndSeedFlythroughStars_FUN_000084dc (0x84dc) backs up the palette before this starts (to 0x99ca) and seeds all 48 star records with fresh random (x, y, z) on entry; ZoomTextInterstitial_RestorePaletteAndReinitGameplayStars_FUN_00008518 (0x8518) restores the saved palette and calls Starfield_InitializeRandomPositions (the same routine §3.4.1 documents) to put the 48 records back into gameplay's screenOffset/phase/xMask layout before play resumes — the record array is fully repurposed and handed back, not duplicated.

Unlike gameplay's incremental scroll-and-plot approach, this screen clears roughly the bottom 19840 bytes of the framebuffer every frame (FUN_00007f96, 0x7f96) before redrawing — the flythrough stars and scaled text don't erase their own trails the way the gameplay starfield relies on objects/background overdraw to do, so a full-region clear is needed each frame instead.

Follow-up session, diagnosed with ReVa against /mydumpat0 using raw disassembly, not the Ghidra decompiler's pseudocode — the decompiler was caught dropping real register-setup instructions it judged "dead" at least three separate times below (see each finding's own detail), which would have been silently wrong if trusted instead of manually walking the raw bytes.

A third small font exists, at 0x9DC0 + 41×176 = 0xB9F0 exactly — contiguous with zero gap after the 16×22 zoom-text font (§3.6.1). 16×16 glyphs, same 4-plane-unmasked/16px-block byte layout as wall tiles and the zoom-text font (128 bytes/glyph = 8 bytes/row × 16 rows), 38 reachable glyphs via a different, shorter charset scan table at 0x8744: "ABCDEFGHIJKLMNOPQRSTUVWXYZ.: 0123456789" (38 chars, 'A' = index 0 — no !><+ punctuation, unlike the zoom-text font's own 0x871A charset). Two unrelated routines share it:

  • HighScoreTable_DrawRowText_FUN_00008b8a (called 10× by HighScoreScreen_DrawTitleAndTable_FUN_00008b40, once per high-score-table row): draws one row's up-to-15-character fixed-width field. Source string (A0) and destination (A1) are set up by the caller once per row (A1 = draw_buffer_base + 0x17d0, then += 0xa00 — 16 scanlines — each iteration); HighScoreTable_DrawRowText_FUN_00008b8a itself hardcodes its own 15-char limit (moveq #0xE,D0) and the font/charset addresses (lea 0xB9F0,A3 / lea 0x8744,A2) in its own preamble — confirmed live via read-memory, not inferred.
  • HudFont_DrawNulTerminatedString_FUN_00001b1e (reached via a bra.w tail-jump from ContinueScreen_PatchAndDrawCreditsCount_FUN_00008f4a, which itself is called by FUN_00008caa and FUN_00008e7c, the continue-game screen's two entry points): draws one NUL-terminated string. ContinueScreen_PatchAndDrawCreditsCount_FUN_00008f4a patches a live digit into a static "CREDITS 3" string at 0x8F6A (s_CREDITS_3_00008f6a[8] = '0' + creditsRemaining), sets A0 = 0x8F6A, A3 = draw_buffer_base + 0x7358 (a fixed HUD position), then tail-jumps into HudFont_DrawNulTerminatedString_FUN_00001b1e — which is why the RTS at the end of HudFont_DrawNulTerminatedString_FUN_00001b1e returns directly to FUN_00008caa/ FUN_00008e7c, not to ContinueScreen_PatchAndDrawCreditsCount_FUN_00008f4a.

Both are self-contained per-string loops with exactly one caller each (unlike 0x803a's three-caller ambiguity, §3.6.1), so hooking each one's own entry PC (0x8b8a, 0x1b1e) is a safe one-shot capture point with no return-address gating needed.

Why only the table title / credits-line title rendered and not the body/count: the high-score title ("HIGH SCORE LOADING LEVEL n") and the continue-game screen's "CONTINUE GAME" + digit selector both happen to reach the already-captured 0x8530/0x8004 paths, so they rendered correctly even before this fix; only the table's actual 10 score/name rows (HighScoreTable_DrawRowText_FUN_00008b8a) and the "CREDITS n" line (HudFont_DrawNulTerminatedString_FUN_00001b1e) were going through this then-uncaptured third font, which is why exactly those two elements were the ones missing on screen.

Per-character horizontal advance — a case where an incomplete disassembly walk gives the wrong answer. Each glyph's own 16-row copy loop (move.l (A4)+,(A1)+ / move.l (A4)+,(A1) / lea (0x9c,An),An, ×16 via dbf) nets a vertical drift of +2560 bytes (16 scanlines) with zero horizontal movement — stopping the disassembly walk there makes it look like successive characters render stacked vertically underneath each other, not left-to-right. Reading one instruction further reveals a trailing lea (-2552,An),An immediately after the row loop, in both functions: 2560 − 2552 = +8 bytes = +16px net horizontal advance, zero net vertical — ordinary left-to-right text after all. Space characters advance the same +16px via their own separate addq.l #8,An in the space-skip loop, without drawing anything.

The title-screen XENON2 logo is a genuine bitmap, not text (no "XENON" string exists anywhere in the binary) — drawn by TitleScreen_DrawLogoZoomBlit_FUN_00008152, called from TitleScreen_LogoStarfieldIntroLoop_FUN_00008242 (itself called from TitleScreen_CreditsScrollDriver_FUN_000082d2, the boot-time logo/credits-scroll screen, and from FUN_0000829c) in a DAT_00007f3c scale-1-to-15 loop, i.e. the exact same zoom-in animation mechanism as the zoom-text font, just scaling a bitmap instead of a glyph.

Second, more consequential case of the decompiler dropping real instructions: Ghidra's decompilation of TitleScreen_LogoStarfieldIntroLoop_FUN_00008242 shows no register setup at all before its TitleScreen_DrawLogoZoomBlit_FUN_00008152() call — TitleScreen_DrawLogoZoomBlit_FUN_00008152's own parameters appear only as in_A0/in_D0w/in_D1w/etc, Ghidra's notation for "inherited from the caller, untraceable". Read at face value, this says the logo's source address and position/size are undiscoverable from statics. They are not: a full manual instruction-by-instruction walk of TitleScreen_LogoStarfieldIntroLoop_FUN_00008242's raw bytes (not its decompiled pseudocode, which omits this entirely) turns up, immediately before the bsr.w 0x00008152:

lea 0x0000CCF0,A0 ; moveq #-112,D0 ; moveq #-80,D1 ; move.w #0xCF,D2 ; moveq #0x35,D3

five real instructions the decompiler never surfaced as any pseudocode line at all. A0 = 0x0000CCF0 is confirmed as the logo bitmap's real address by more than just this one read: it is byte-for-byte contiguous with both font tables above with zero gap or overlap — 0x9DC0 + 41×176 = 0xB9F0 exactly, 0xB9F0 + 38×128 = 0xCCF0 exactly — three independently-derived data tables chaining perfectly, address to address, is strong corroboration that this is real data layout, not a decompiler artifact or coincidence. D2 = 0xCF (207) and D3 = 0x35 (53) match a 208×54px native bitmap almost exactly against the only available gap before the next already-catalogued region (bullets at 0x0E2E8, §3.5): 208px / 16 × 8 bytes/row × 54 rows = 5616 bytes against a 5624-byte gap — 8 bytes of slack.

Position/scale formula (same manual-disassembly method, against TitleScreen_DrawLogoZoomBlit_FUN_00008152 itself):

x = ((D0 * scale) >> 4) + 160        scale = live DAT_00007f3c (same variable §3.6.1 reads)
y = ((D1 * scale) >> 4) + 100

These are the destination's top-left pixel coordinate, not a center — confirmed by how the real code actually uses the value, not just by its shape: after only a byte/word-alignment adjustment, it becomes the starting address the row-doubling copy loop writes to, and that loop only ever adds to it (advancing down/right through the bitmap) — there is no halving or centering anywhere in TitleScreen_DrawLogoZoomBlit_FUN_00008152's own code. A first implementation of this hook wrongly subtracted destW/2/destH/2 from these values, by false analogy with the zoom-text font's own position formula (§3.6.1), which really does compute a center (DAT_00007f3e, read back and halved inside 0x803a's own scaled-branch setup) — a different function with a genuinely different convention. The bug was caught because it produced a mostly-off-screen negative position at high scale, contradicting the real game showing the logo fully on-screen — a good example of why matching a formula's shape to a sibling feature isn't the same as confirming its actual use.

destW/destH reuse the zoom-text font's exact rotate-and-popcount row-selection technique against the same 16-entry table (0x876c) — confirmed via disassembly that TitleScreen_DrawLogoZoomBlit_FUN_00008152 reads that table twice per call, once per axis, each driving an independent bit-doubling loop — generalized from the font's hardcoded native height of 22 to this bitmap's own native width (208) and height (54). Re-derived by hand for the width axis specifically (since its inner loop turned out structurally different from height's on closer reading — a nested 16-iteration sub-loop deciding when to fetch new source words runs independently of the mask-bit test that decides when to write, unlike height's single flat loop): because the horizontal loop runs exactly LOGO_NATIVE_WIDTH (208) iterations and 208 is an exact multiple of the mask's own 16-bit rotation period (208 = 13×16), the mask completes exactly 13 whole rotation cycles across that loop, so the total write count is exactly 13 × popcount(mask) — precisely what running the same flat rotate-and-count loop 208 times already computes (13 full 16-bit cycles of the same popcount, summed). The width formula's apparent structural difference from height's therefore doesn't actually change the answer here, specifically because 208 is a clean multiple of 16 — this would not hold for an arbitrary native width that wasn't.

Because TitleScreen_LogoStarfieldIntroLoop_FUN_00008242 has a second call site (via FUN_0000829c) that may load different literal values into the same five registers, the capture hook reads A0/D0/D1 live at TitleScreen_DrawLogoZoomBlit_FUN_00008152's own entry PC rather than hardcoding the confirmed constants above — it automatically follows whichever caller is actually active, with no caller-specific branching needed (unlike 0x803a's three-caller return-address gate, §3.6.1).

The logo "disappearing" after the zoom-in was never a size bug at all — it was a missing persistence mechanism. The real game only calls TitleScreen_DrawLogoZoomBlit_FUN_00008152 during the zoom-in (DAT_00007f3c = 1..15); afterward the logo stays visible only because FUN_00007f96 clears just the bottom ~19840 bytes of the framebuffer each frame (§3.6.2), never touching the top region the logo occupies — a "draw once, then it just physically persists in the buffer" effect, the same architecture already used for the status bar panel template (§4.1). A per-frame DrawCommandStream capture has no equivalent to "stopped redrawing it, but nobody erased it either", so the hook needs its own explicit persistence: DrawCommandStream_OnLogoDrawInstructionFetch now only updates a small cache (s_logoCache) when it fires; a new DrawCommandStream_OnLogoFrameComplete, called every frame from the buffer-swap write handler (the same "once per completed frame, not an instruction-fetch hook" mechanism the status bar capture uses), pushes that cached quad every frame for as long as it stays valid. Confirmed live: with this fix, the logo now zooms in and then stays correctly on screen for the rest of the title/credits sequence, matching the reference screenshot (logo and scrolling credits text visible together). The cache is invalidated the instant gameplay's own wall-tile draw fires (DrawCommandStream_OnBackgroundDrawInstructionFetch, TILE_DRAW_FUNCTION_ENTRY_PC) — that function never runs on the title/credits screen, so seeing it fire is an unambiguous signal that screen has been left and the cached logo must stop appearing.

3.6.4 Correction: the title screen's own zoom-in text was being excluded too

TitleScreen_CreditsScrollDriver_FUN_000082d2 (the title/credits screen, §3.6.3) drives its own per-line zoom-in text reveal through 0x803a's scaled path — bsr.w 0x0000803a at 0x84a4, returning to 0x84a8 — using the exact same live $7f3c/$7f3e scale/position convention as ZoomTextInterstitial_MainLoop (0x8530). The original 0x803a caller gate (§3.6.1) only accepted the interstitial's own return address (0x859a), so every title-screen text line was invisible for the entire scale 1-15 zoom-in and only snapped into view once DAT_00007f3c crossed the unscaled threshold (16) and fell through to the ungated 0x8004 path — exactly the "only visible once fully zoomed in" symptom observed live. Fixed by accepting both 0x859a and 0x84a8 as legitimate scaled-text callers (ZoomTextScaledCallerIsAccepted, src/drawCommandStream.c) — safe to do without reintroducing the original two-copies-of-text bug that motivated the gate, since position/size for the scaled path were already computed from the live $7f3c/$7f3e values rather than anything caller-specific. The third caller (HighScoreScreen_DrawTitleAndTable_FUN_00008b40's high-score-table title line) still needs no entry in this gate: it always forces DAT_00007f3c = 0x10 (unscaled), so it never reaches the gated scaled-entry PC at all — it falls straight through to the ungated 0x8004 path on every call.

3.6.5 The HD version's intro (IMAGEWORKS, BITMAP BROTHERS, the grey XENON 2 banner)

The hard-disk version (RUNME.TOS → FILES\X2I) opens with about 30 seconds the game code above never draws: "IMAGEWORKS PRESENTS", "A BITMAP BROTHERS … GAME", then the grey XENON 2 MEGABLAST banner with the Rhythm King and Bomb the Bass logos and "INSERT OTHER DISK", before it jumps to the title program at $bf5ac. It is a separate program in $40000–$41800 with its own double buffer ($40a3c shown, $40a40 drawn) that writes the shifter base itself, so 0x406 never changes and no game-frame boundary happens. Found by attributing screen writes in a MemoryAccessLog dump to PCs, then disassembling the intro RAM (Ghidra program /xenon-intro-ram).

  • Per frame FUN_00040ccc: clear the back buffer ($40c7a; with $40a33 set only below the banner), draw one scaled picture ($40b1a), call three per-screen hooks through $40a46/$40a4a/ $40a4e, wait for the VBL and swap ($40a7c; its wait loop branches back to its own entry, so the frame hook sits on the swap's $ff8201 write at $40aca).
  • Scaled picture $40b1a: A0 a 2-bitplane source (per 16 pixels one plane-0 then one plane-1 word), D0/D1 its offset from the screen centre, D2/D3 width/rows minus one, zoom $40a44 = 0–32. Position (D0*zoom)>>5 + 160, (D1*zoom)>>5 + 100; rows and columns are kept where the zoom's 32-bit mask (table $40bf6) has a bit set, rotated once per row/column. At zoom 32 a picture at or after $42722 takes the fast path $40adc, which copies a separate 16-colour full-size version at $444da + 2*(A0 - $42722) — the zooming 4-colour picture and the full-size one differ.
  • Text $40d84: A0 string, A3 screen address; 16×16 2-plane glyphs at $42022 (64 bytes each), charset "ABCDEFGHIJKLMNOPQRSTUVWXYZpc" at $416c9 (p/c are ℗/©), ORed in. $40dc6/$40e54 zoom a string's letters in/out one at a time through $40b1a.
  • Pictures: $42722 Bomb the Bass (96×75), $42e2a Rhythm King (80×81), $4347e Bitmap Brothers (80×103), $43c8a Imageworks (64×133); banner $4804a, 320×94 in 4 bitplanes, drawn by $40f74.
  • Palette: one grey ramp ($40a5c). Before the third screen the script ($4132c) blanks the palette ($4110e), fades it in to the ramp ($41016: ramp minus d0, d0 = $666 down to 0, 3 VBLs a step), then flashes to white ($4106e: ramp plus d0, one step per VBL, the banner drawn into both buffers at the peak, then back down). Every step moves each channel of every entry by the same amount, clamped to 0–7. $40fcc (fade out) is not called by the script.

The reconstruction (DrawCommandStream_OnIntroInstructionFetch, src/drawCommandStream.c) collects each frame's quads and pushes them at the swap after a full-screen palette-0 quad (which carries the white flash) and the banner, which is drawn only once. The graphics are custom sprites (xenon_tools/custom_sprites/extract_intro_sprites.py, keyed 0x02000000 | address) in the shared atlas, baked with the ramp. The fades change only the palette, so the sprites follow them in the renderer: DrawCommandStream_GetIntroPaletteFade compares the live palette with the ramp at $40a5c (the step is the most negative per-channel difference, else the most positive; clamping never hides it in every entry), screentrace.c sets SPRITE_QUAD_FLAG_PALETTE_FADE on the intro frame's textured quads and SpriteStreamRenderFrame.paletteFadeSteps, and both fragment shaders add steps * 34/255 per channel, clamped to 0–238/255. Being an offset in colour space, it works unchanged for the 4× HD atlas.

Verified against the original display with desktop AVI pairs (low-res and --xenon-hires true): same geometry frame by frame, and the banner's brightness through the flash matches the ST's within one grey level (238 at the peak, then 227, 217, 207, 196, 184, 173). Remaining differences: the reconstruction presents a frame one VBL earlier than the ST shows it (the ST latches the new screen base at the next VBL); on the VBL the banner is drawn the ST shows it half drawn, the reconstruction whole; and in HD a few upscaled edge pixels darker than any ST level the banner uses stay faintly visible at +6.

4. Capture pipeline (src/drawCommandStream.c / .h)

DrawCommandStream is a ring buffer of DrawMaskedSpriteEntry records (frame, spriteId, screen x/y, resolved atlas rect, isFlash, isBackgroundWrap), filled by independent instruction-fetch hooks, all wired into the same per-instruction chokepoint (ScreenTrace_LogRead, src/screentrace.c):

Hook Fires at Captures
DrawCommandStream_OnInstructionFetch 0x3ee4 (object dispatch JSR) Verified ordinary object draws (ship/enemies/bullets/particles); skips no-op, composite, and unrecognized contracts rather than rendering stale fields
DrawCommandStream_OnBossCompositeDrawInstructionFetch common entries 0x21ac / 0x2504, plus level-1 center call 0x5085e Normal tile grids, custom solid/mask hit grids, and the level-1 eye center; common entries also cover relocated later-level callers
DrawCommandStream_OnSideCannonDrawInstructionFetch 0x6cd0, 0x6d0c, 0x6d2c, 0x6d30 Parent state, two real endpoint sprites, then direct-framebuffer body of each player side-cannon shot
DrawCommandStream_OnTileDrawInstructionFetch 0x1eaa / 0x1f2c / 0x2150 (post tile-code-fetch, all 3 row-bands) Every individually-placed wall tile
DrawCommandStream_OnBackgroundDrawInstructionFetch 0x1dd0 (tile-draw function entry, fires first each frame) One quad/frame for the scrolling background mosaic

Each hook resolves its spriteId/tile-address against the loaded sprite atlas (SpriteAtlas_FindRect, src/spriteAtlas.c/.h) and only pushes an entry if found. Because the background hook fires at the function entry, before either of the other two hooks can fire that frame, its entry always lands first in capture order — which gives correct z-order (background behind walls/objects) for free, since the renderer draws entries in capture order.

A fourth hook, DrawCommandStream_OnMemoryWrite, watches writes to the game's draw-buffer-base pointer (0x406) to detect completed buffer swaps and advance the frame counter.

4.1 Sprite atlas region scanning (src/xenonRender.c)

Separately from the live capture hooks, a one-time offline scan (triggered by opening the "Masked Sprite" debug window) walks known ST memory regions (g_spriteMemoryRegion[]) and decodes every sprite/tile/background image it finds into an in-memory catalog (RecordRenderedSprite), which gets dumped to spritecatalog.bin (+ stram.bin, palette.bin) for the .NET atlas packer to consume. This is how the atlas gets built in the first place — the live capture hooks above only ever look up into an already-built atlas, they don't add to it.

Key regions: SPRITE_REGION_TYPE_SPRITES_WITH_MASK (objects), SPRITE_REGION_USED_TILES (wall tiles, walks the variable-stride packed blob starting at index 1 — see §3.2), SPRITE_REGION_TYPE_ BACKGROUND (the mosaic, exported as one big image).

5. Atlas packing (hatari_dotnet/)

A .NET tool reads spritecatalog.bin (RSPR file format, src/includes/renderedSpriteExport.h) plus the raw ST memory/palette dumps, decodes every catalogued sprite via SpriteRenderer.cs (mirrors every C-side decode format above), and packs them into one atlas image via SpriteAtlasExporter.cs, writing spriteatlas_packed.png (human inspection) and a raw pixel dump + index (spriteatlas_packed.raw/.idx) that the C side loads directly (no PNG decoding needed at runtime — src/spriteAtlas.c).

6. GPU rendering (src/sdlGpuRenderView.c, src/screentrace.c, src/shaders/)

RENDER_VIEW_SPRITE_STREAM is the debug window that reconstructs a live frame purely from DrawCommandStream + the packed atlas, with zero CPU-side sprite rasterization — everything is textured GPU quads.

6.1 Single draw call per frame

Every sprite/tile/background quad for the frame being replayed gets appended to one CPU-built SpriteStreamVertex array (SpriteStreamVertexBuilder_AppendEntry, screentrace.c, driven by DrawCommandStream_ForEachInFrame, oldest-first = capture order = correct z-order), uploaded once, and drawn with exactly one SDL_DrawGPUPrimitives call. This is a deliberate, non-negotiable architectural constraint — no per-sprite draw calls, no per-sprite pipeline rebinding.

SpriteStreamVertex: {x, y (NDC), u, v (normalized atlas UV), flash, bgWrap, flatColorIndex, atlasBank, paletteFade}. The vertex/pixel format is entirely separate from the shared Vertex struct used by the other 3 (non-sprite-stream) debug window types — deliberately, since this window already has its own vertex buffer, so there's no sharing constraint forcing a shared format, and keeping it separate means those other windows stay untouched by sprite-stream-specific changes.

6.2 GPU-side effects, resolved by the shader (not the CPU)

Flash tint (isFlash/flash): reproduces §2.4's forced-color-7 effect. The CPU sets a 0/1 flag per quad; the fragment shader mixes the sampled atlas color with a uniform flashColor (recomputed every frame from the live ST palette, index 7 — palette can change at runtime, so this is never a baked-in constant).

Palette fade (SPRITE_QUAD_FLAG_PALETTE_FADE/paletteFade): for quads whose baked colours follow a whole-palette fade (currently only the HD version's intro, §3.6.5), the shader adds the frame's fade in ST levels (paletteFadeSteps * 34/255) to each channel and clamps to the ST range.

Everything else samples the atlas texture directly (sprite_atlas_fragment.glsl: texture(atlasTex, uv)) — i.e. every regular sprite/tile/font/logo quad's color is whatever RGBA was baked into the atlas once, offline, by the .NET packer (§5), from whatever ST palette happened to be live at the moment the one-time sprite scan ran (opening the "Masked Sprite" debug window). flashColor above and flatPalette[16] (§3.4.3/§3.6.2's stars, the status bar's shield meter) are the only two paths that resolve a color from the live palette per frame — regular atlas-sampled sprites never do. This is a known, confirmed-live source of wrong colors on any screen whose real palette differs from (or animates away from) whatever was live at scan-time — the title screen's own per-frame palette cycle (§3.6.2's 0x86fa table copied into $FFFF8240-$FFFF825C every frame) is exactly such a case: the logo/HUD-font/zoom-text glyphs drawn there all show their scan-time color, not the cycling one.

Live symptom this actually produced: reading the zoom-text font's own glyph bytes directly (0x9DC0, glyph 'A') shows palette indices 5/7/8 used for ordinary beveled-letter edge highlights — the same index range (4-7) gameplay tunes to look like bright stars. Baking that glyph with a gameplay-time palette snapshot made those highlight pixels show up in spriteatlas_packed.png/spriteatlas_captured.png as small white flecks that look exactly like stray star sprites, even though nothing star-related is actually involved — confirmed by checking SpriteAtlasExporter's canvas handling (a freshly zero-initialized buffer per export run, only ever written by SpriteRenderer.Render for an actually-placed sprite rect) ruled out stale-buffer contamination as the cause.

Temporary workaround shipped, not a fix for the root cause above: rather than the palette-index-channel approach originally discussed, hatari_dotnet now bakes the zoom-text font/HUD font/logo range (0x9DC0-0xE2E0, i.e. all three of §3.6.3's regions in one contiguous span) using a second, separate palette snapshot (palette_interstitial.bin) instead of the main palette.bin.

An earlier version of this fix captured that second snapshot live, hooking the three relevant capture functions to dump STRGBPalette[16] the first real moment any of the affected screens was confirmed active. Rejected: it would require the user to manually visit every affected screen once per capture session before the atlas could be rebuilt correctly for all of them. The shipped version instead reads a fixed ST address directly, with no dependency on which screen is currently showing: InterstitialPaletteDump_SaveToFile (src/xenonRender.c) reads the same 16-word (32-byte) color table at 0x000086FA that both ZoomTextInterstitial_MainLoop_FUN_00008530 and TitleScreen_CreditsScrollDriver_FUN_000082d2 copy verbatim into all 16 live ST palette hardware registers ($FFFF8240-$FFFF825C) whenever they run — see §3.6.2's correction for why this is 16 words covering every register, not the 8-words/even-registers-only reading originally documented there — confirmed via disassembly to be ordinary static data in the game's own binary, not a runtime-only scratch variable, so it's present and readable in ST RAM at all times regardless of which screen happens to be showing at capture time. Each raw word is run through ST2RGB[] (src/conv_st.c) — the exact same live conversion table STRGBPalette[] itself is built from (AdjustLinePaletteRemap), so this is a real, correctly-converted color for all 16 registers, not an approximation or a partial (8-of-16) fix. Both files are now written by the exact same one-shot "Masked Sprite" debug-window trigger (DebugWindow_UpdateFromSTLowResBase, src/screentrace.c), so no extra manual step is needed at all — hatari_dotnet's Program.cs falls back to the main palette only for captures taken before this fix existed (missing file), not for any live screen-visit reason.

This is explicitly still just one static snapshot, chosen deliberately over the palette-index/live-remap approach because the atlas is intended to be upscaled later, and a palette-indexed atlas doesn't survive that (upscaling filters need real color data to interpolate against, not indices to remap afterward). It does not reproduce any live per-frame palette animation either screen might do — table 0x86FA's content is read as-is, whatever rotation phase (if any) happens to be sitting there at capture time; whether it actually rotates over time at all is not confirmed either way (see §3.6.2's own note that the mechanism producing genuine animation, if there is one, was never fully traced — only that the palette write itself happens every frame from 0x8530, and that this session's own read of TitleScreen_CreditsScrollDriver_FUN_000082d2 found it setting the palette from this table just once at screen entry, holding it fixed for the credits scroll, not re-copying every frame the way 0x8530 does — so the two screens may not even behave the same way here). This workaround only replaces one wrong static snapshot (gameplay's) with a less-wrong static snapshot (real interstitial-palette data, correctly converted, whatever moment of it), not a full animation. If the root cause ever needs fully fixing (exact per-frame color reproduction, if the effect turns out to be real animation rather than a one-time palette swap), the palette-index-channel + live-remap approach is still the correct direction — it was set aside here specifically for the upscaling constraint, not because it was found to be wrong.

Background wrap (isBackgroundWrap/bgWrap): reproduces §3.3's seamless vertical scroll. The CPU computes scrollY and pushes one quad whose atlas-V deliberately reads past the mosaic's own 192-line sub-rect; the fragment shader wraps the V sample back within that sub-rect via mod() (v = bgWrapVStart + mod(v - bgWrapVStart, bgWrapVPeriod)), driven by a small per-frame uniform. No CPU-side quad-splitting and no GPU hardware texture-repeat sampling (which would need the background carved into its own dedicated texture to avoid bleeding into neighboring atlas content) — just a manual wrap computed from the quad's own known sub-region bounds. Also reused for GPU clip-based row-band handling: the wall tile top row-band's Y position is captured as its logical (possibly negative) unclipped position (-fineScroll) rather than the always-y=0 destination the game itself writes to, letting ordinary GPU viewport clipping discard whatever falls outside the screen — no CPU-side row-count clipping needed there either.

6.3 Other debug window types

RENDER_VIEW_MASKED_SPRITES (the sprite-atlas browser used to trigger the one-time region scan), RENDER_VIEW_LOWRES_VRAM (raw ST screen passthrough), RENDER_VIEW_MEMORY_MAP (read/write/exec heatmap) all share a separate, simpler Vertex{x,y,u,v} format and a fixed full-screen-triangle draw — unaffected by anything in §6.1-6.2.

7. Quick address/symbol reference

Address Symbol What
0x3e42 processes_linkedLists_draw_ship_FUN_00003e42 Per-frame: update pass then draw pass, over all 5 object lists
0x3ec4 UpdateObjectListCallProc1_FUN_00003ec4 Object list update dispatch
0x3ed0 (PC inside above) The JSR (A1) hook point for updateProc (§2.5) — not yet wired into drawCommandStream.c
0x3ed8 DrawObjectListCallProc2_FUN_00003ed8 Object list draw dispatch
0x3ee4 (PC inside above) The JSR (A1) hook point for objects
0x39a2 NoOpDummyHandler_FUN_000039a2 "Disable this handler slot" stub (also a legal updateProc)
0xe1e-0xf6d ScriptedSineMotionPeriodicSpawner_UpdateProc_Thunk_00000e1e (first slot) Shared 56-slot "install proc by ID" JMP trampoline (§2.7); full resolution in the first slot's Ghidra plate comment
0x10a2 draw_ship_maybe_FUN_000010a2 Normal masked-sprite blitter
0xe24 (thunk) -> 0x10a2
0x1594 DrawFlashFrame_RevertToNormalDraw_FUN_00001594 Flash-tint effect
0xe78 (thunk) -> 0x1594
0x67c6 (inside ShipUpdate_ProcessInputMovementCamera_FUN_00006734) Installs 0x1594 directly (ship fire-flash)
0x1cd6 DrawPreshiftedBulletObject_FUN_00001cd6 Bullet blitter
0x2b1a Starfield_UpdateAndDraw_48Stars Updates and draws all 48 stars, once/frame
0x2aba Starfield_UpdateVerticalPositions Applies depth-dependent vertical motion and 192-line wrap
0x2a98 (signed star-speed lookup table) Maps scroll speed n to n * 0x180 subpixel units
0x3d1dc Starfield_48StarRecords Base of 48 consecutive 6-byte star records
0x7f0a Starfield_InitializeRandomPositions Randomizes star positions and X bit masks
0xcd8 CameraScrollAppliedDelta_cd8 Signed effective wall/camera delta after clamping; also consumed by starfield/object scroll logic
0x1dd0 ST_DrawGeneratedBackgroundFromTileMap_FUN_00001dd0 Wall-tile + background draw, once/frame
0x1ea8/0x1eaa (top row-band fetch/commit)
0x1f2a/0x1f2c (middle row-band fetch/commit)
0x214e/0x2150 (bottom row-band fetch/commit)
0x59c42 (tile graphics blob base) PTR_DAT_0004f008
0x4f008 PTR_DAT_0004f008 Points to 0x59c42
0x4f004 PTR_DAT_0004f004_tileMapBase Level tilemap array base
0x6989c (level-1 background mosaic base) One value selected by PTR_0004f000_points_to_Background; later levels relocate it
0x4f000 PTR_0004f000_points_to_Background Live per-level mosaic-base pointer (e.g. $6989c on level 1, $6ce6c on level 2)
0x436 PTR_DAT_0436_currentTileMapPointer Live background scroll cursor
0x702c BackgroundScrollCursor_UpdatePerFrame_FUN_0000702c Updates the above, once/frame
0xcd6 (wall-tile fine-scroll fraction, 0-15)
0xccc (default camera scroll delta; coarse tile byte offset is at 0xcce)
0xcda/0xcdc CameraScrollRequestedDelta_cda / parallax phase Requested wall/camera delta plus the phase used to derive the background's slower scroll step
0x406 (draw-buffer-base pointer) Written on buffer swap — frame-boundary hook
0x76b4 OnLifeLost_SwapTurnAndRespawn_FUN_000076b4 Turn-swap + respawn entry point (§3.6)
0x7586 SetupRespawnAndShowGetReady_FUN_00007586 Resets ship spawn position, drives the interstitial screen
0x84c4 ZoomTextInterstitial_ShowScreen_FUN_000084c4 Zoom-text interstitial driver (§3.6)
0x84dc ZoomTextInterstitial_SavePaletteAndSeedFlythroughStars_FUN_000084dc Palette backup + reseeds the 48 star records as 3D (x,y,z)
0x8530 ZoomTextInterstitial_MainLoop_FUN_00008530 Per-frame: zoom script step, text draw, star flythrough, palette cycle
0x85b4 Starfield_DrawPerspectiveFlythrough_FUN_000085b4 Perspective "fly into the starfield" effect (§3.6.2)
0x8518 ZoomTextInterstitial_RestorePaletteAndReinitGameplayStars_FUN_00008518 Restores palette, reinitializes gameplay starfield
0x8004 blitting_DrawFixedLengthText16x22 Fixed-size 16×22 text blit; plate comment documents the font (§3.6.1)
0x803a blitter_drawScaledText_FUN_0000803a Scaled 16×22 text blit used by the interstitial screens
0x7fd8 (charset linear-scan lookup) Maps a character to its glyph index
0x9dc0 (16×22 font glyph table, 41 glyphs) SPRITE_REGION_TYPE_ZOOMTEXT_FONT in g_spriteMemoryRegion[] (§3.6.1)
0x871a (charset scan table, 41 chars, 'A' first) "ABCDEFGHIJKLMNOPQRSTUVWXYZ.:><0123456789+" -- one byte past the ! at 0x8719
0x876c (16-entry scale bit-mask table) Row-doubling/dropping pattern per scale value 0-15
0x867c (8-entry depth→bitplane-pattern table) Perspective starfield's per-depth-band palette index
0x86fa (16-word/32-byte palette table, ends exactly at 0x871a) Copied via movem.l into all 16 $FFFF8240-$FFFF825C registers on the title/interstitial screens (§3.6.2 — corrected this session from an earlier 8-word/even-only reading)
0x8b40 HighScoreScreen_DrawTitleAndTable_FUN_00008b40 High-score screen: draws the title line then all 10 table rows (§3.6.3)
0x8b8a HighScoreTable_DrawRowText_FUN_00008b8a Draws one high-score-table row (up to 15 chars) via the 0xB9F0 HUD font (§3.6.3)
0x8f4a ContinueScreen_PatchAndDrawCreditsCount_FUN_00008f4a Patches the live credits digit into "CREDITS n", tail-jumps into 0x1b1e (§3.6.3)
0x1b1e HudFont_DrawNulTerminatedString_FUN_00001b1e Draws one NUL-terminated string via the 0xB9F0 HUD font (§3.6.3)
0xb9f0 (16×16 HUD font glyph table, 38 glyphs) SPRITE_REGION_TYPE_HUDTEXT_FONT in g_spriteMemoryRegion[] (§3.6.3)
0x8744 (charset scan table, 38 chars, 'A' first) "ABCDEFGHIJKLMNOPQRSTUVWXYZ.:0123456789" — no punctuation beyond .:
0x8152 TitleScreen_DrawLogoZoomBlit_FUN_00008152 Title-screen XENON2 logo zoom-in blit, reads A0/D0/D1/D2/D3 from its caller (§3.6.3)
0x8242 TitleScreen_LogoStarfieldIntroLoop_FUN_00008242 Boot logo/starfield intro loop; sets up TitleScreen_DrawLogoZoomBlit_FUN_00008152's registers, confirmed only via raw disassembly (§3.6.3)
0xccf0 (title logo bitmap, 208×54px) SPRITE_REGION_TYPE_LOGO_XENON2 in g_spriteMemoryRegion[] (§3.6.3)
0x40ccc Intro_DrawFrame HD-version intro, per frame: clear, scaled picture, three hooks, swap (§3.6.5)
0x40b1a Intro_DrawScaledPicture Zooms a 2-plane picture or glyph; zoom $40a44 0-32, masks $40bf6 (§3.6.5)
0x40adc Intro_DrawFullSizePicture Full-size fast path: copies the 16-colour version at $444da + 2*(A0-$42722)
0x40d84 Intro_DrawText 16×16 2-plane font at $42022, charset at $416c9
0x40f74 Intro_DrawBanner Grey XENON 2 banner, 320×94 4-plane at $4804a
0x40a7c Intro_WaitVblAndSwap Swaps $40a3c/$40a40 and writes the shifter base at $40aca

All of the above are annotated directly in the Ghidra project (/mydumpat0) with plate/EOL comments carrying this same detail, cross-referencing each other and this file's findings.

8. Known limitations / deferred work

8.1 WASM raster wait, buffer-swap latency, and audio scheduling

The browser build currently applies HATARI_WASM_PATCH_XENON_RASTER_WAIT after restoring the Xenon snapshot. It replaces the backward branch at $2904 with a NOP, removing the expensive poll of Shifter register $FF8207 in ST_WaitAndSwapScreens_FUN_000028dc.

Instrumentation of 82 swaps established the following:

  • Xenon requests one buffer swap every four VBLs, and the patched build still performs exactly one swap in each eligible VBL. Multiple swaps in one VBL are therefore not the flicker cause.
  • The unmodified routine intends to swap at approximately scanline 192, after the 320x192 playfield has been scanned out and shortly before the next VBL.
  • With the temporary NOP patch, the swap occurs around scanline 0-2 of the next eligible frame. The frequency is correct, but the raster position is too early and can expose parts of two buffers during one native-ST frame, causing flicker.

Open tasks:

  1. Replace the patched busy wait with a resumable WASM/browser wait that resumes at emulated scanline 192 in the current frame, then lets Xenon's existing swap code execute. Hatari must continue processing the intervening emulated cycles, HBL interrupts, timers, and audio events.
  2. Do not use "swap at the next VBL" as the permanent solution. It would stabilize the image but delay presentation by one 50 Hz frame (about 20 ms), which is an unacceptable latency increase.
  3. Later, investigate moving Xenon's sample rendering/mixing to the browser side, preferably an AudioWorklet fed by emulated sound commands or sample data. Define buffering, clock ownership, underrun recovery, and synchronization with emulated VBL/HBL time before implementation. This could reduce main-thread scheduling constraints, but it does not replace the line-192 swap fix.
  4. Keep ENABLE_WASM_XENON_SWAP_DIAGNOSTICS available while implementing the fix and verify that swaps remain one-per-eligible-VBL and move back to approximately HBL/scanline 192.
  • updateProc capture pipeline doesn't exist yet — drawCommandStream.c only ever hooks 0x3ee4 (the draw dispatch). Nothing currently hooks 0x3ed0 (§2.5, the analogous update dispatch), so there's no live per-object log of updateProc values, call frequency, or the fields each one reads/writes. §2.6-2.9 document what's already known from static analysis — notably, the full vector-table sweep (§2.9) means live capture is no longer needed to discover new updateProcs (static analysis got essentially all of them without playing the game); it's now mainly useful to confirm the static findings against real play and to resolve the specific open questions §2.9 flags (which context swaps in the second RNG stream, hit-reaction family: proc3 vs updateProc, the 0x3d86/0x3d9c duplication).
  • Frame extrapolation (interpolating extra frames between the game's own VBL-throttled updates) — implemented since this was written; see xenondoc/plan-atlas-upscale-and-frame-interpolation.md for the current design and status. src/spriteStreamInterpolation.c extrapolates gameplay object/starfield/background position from cached real frames (never re-executing game/CPU logic, so §2.8's RNG hazard below still doesn't apply to any of it), with per-quad-type treatment for wall tiles, the background mosaic, scaled zoom-text/logo quads, and both starfield modes. One known, deliberately-deferred residual: vertical-scroll starfield extrapolation still visibly "snaps back" a little. Root cause (confirmed via disassembly of Starfield_ UpdateVerticalPositions, §3.4.2): a star's screen Y is driven by a phase accumulator that advances a fixed sub-pixel amount every frame, only stepping the visible Y by one scanline when that phase crosses a threshold — the real per-frame delta dithers (0,0,1,0,1,...), averaging to the true speed only over several frames. No smooth extrapolation curve can exactly reproduce that step function; a wide-baseline average-velocity mitigation (matching the fix already applied to the background mosaic's own analogous dithered-scroll-speed issue) has been applied and reduces the effect, but doesn't eliminate it. Deliberately left open for now — plan is to revisit once gameplay resolution is upscaled past native 320×200, since higher resolution gives sub-pixel star motion more headroom to be represented smoothly instead of needing to snap between whole native-pixel steps. See the plan doc above for the full diagnosis and the specific code location.
  • Background mosaic "empty cell" fallback only — tiles are captured individually; the mosaic quad fills every empty cell uniformly. This is correct (matches the real game), just noting there's no per-cell distinction needed there.
  • No horizontal background scroll implemented — none observed live; if the game turns out to need it in some level, the background capture would need an X term added.
  • Wall tile fine-scroll preshift variants not reproduced — only the canonical (variant 0) source is decoded into the atlas; sub-pixel horizontal tile scroll isn't visually reproduced (same simplification already accepted for bullets).
  • SPRITE_FORMAT_4PLANES_96WIDTH / FONT_8WIDTH / FONT_4WIDTH — present in the format enum, not covered by this document; HUD/text rendering, not yet part of the live capture pipeline.
  • Zoom-text interstitial screens (§3.6) mostly captured now, five gaps closed across two sessions (§3.6.3, §3.6.4) — the 16×22 font, the high-score table body (0xB9F0 HUD font, HighScoreTable_DrawRowText_FUN_00008b8a), the "CREDITS n" counter (same font, HudFont_DrawNulTerminatedString_FUN_00001b1e), the title-screen logo (0xCCF0 bitmap, TitleScreen_DrawLogoZoomBlit_FUN_00008152), and the title screen's own zoom-in text reveal (accepting 0x84a8 as a second valid 0x803a caller) all now have g_spriteMemoryRegion[] entries and/or capture hooks/gate fixes, confirmed working live for the table body and credits count after the required atlas re-scan/re-pack/restart cycle (§9) — the first live round after adding those two showed them still missing purely because that cycle hadn't been run yet, not a code bug. The logo needed a second real fix beyond the atlas rebuild: an earlier version of its hook wrongly treated its position formula as a bitmap center and subtracted half the destination size, pushing most of it off-screen (§3.6.3's position/scale formula section has the corrected derivation) — confirmed fixed live (visible while zooming in), but it then disappeared right after the zoom-in completed. That turned out to be a third, structural issue, not a size-formula bug: the real game only ever calls TitleScreen_DrawLogoZoomBlit_FUN_00008152 during the zoom-in and relies on the framebuffer simply never being re-cleared in that region afterward to keep the logo visible (§3.6.2's bottom-only clear) — a "physically persists, no further redraws" behavior a per-frame capture ring buffer can't reproduce by hooking the draw call alone. Fixed by caching the last real draw and re-pushing it every frame until gameplay's own wall-tile draw fires (§3.6.3's last paragraph has the full mechanism) — confirmed live: all five gaps (font, table body, credits count, logo zoom-in, logo persistence) now render correctly together, matching the reference screenshot. Bounded diagnostics (HudFontLogHookFired, and equivalents inline in DrawCommandStream_OnLogoDrawInstructionFetch, both in src/drawCommandStream.c) remain in place from the debugging process, logging the first several times each new hook fires with the exact register values read and the resolved (or failed) atlas rect. Separately: (1) the sprite atlas baking RGBA colors once at pack time (from whatever palette was live during the one-time offline scan) rather than resolving a live palette index per pixel — see §6.2's flashColor/ flatPalette mechanism for the only two exceptions to that — caused the zoom-text font's own beveled highlight pixels to bake as stray white flecks that looked exactly like star sprites in the exported atlas PNGs. Worked around, not fixed — see §6.2's own writeup for the full derivation and why a live-palette-lookup shader path (the actual fix) was set aside in favor of a second static palette snapshot, specifically to keep the atlas plain-RGBA for a planned future upscale pass. The underlying "capture pipeline never observes the perspective starfield's own live palette-register writes ($FFFF8240-$FFFF825C, §3.6.2)" limitation this stems from is still open. (2) reproducing the zoom-text font's own scale animation faithfully would need the zoom-script table format (signed deltas, 0x11 marker byte, negative terminator) pinned down precisely. Resolved, not a separate bug: the high-score name-entry screen (initials editing) initially showed a garbled, single glyph (rendering only for 'A', nothing for other letters) — this was suspected to be a separate, not-yet-identified draw routine for the interactive cursor, distinct from HighScoreTable_DrawRowText_FUN_00008b8a's fixed table rows. It wasn't: name entry goes through that exact same function/font path, and the garbling had the same root cause as the table body and credits count above (atlas not yet rebuilt) — confirmed live, name entry now renders correctly with no separate routine needed.
  • Status bar was rendering on every screen, not just during gameplay — fixed. DrawCommandStream_ OnStatusBarFrameComplete (§4, "Status bar capture") fires once per completed frame from the buffer-swap write handler, unconditionally — it has no gameplay-vs-other-screen check of its own, it just reads whatever bytes happen to be at the player-state addresses and pushes HUD quads for them. On real ST hardware, that HUD strip is only ever drawn during actual gameplay; the title screen, credits scroll, high-score table, and continue-game screen all use the full framebuffer with no HUD reserved. Once the title-screen logo/text fixes above made those screens render correctly for the first time, the always-present status bar became visible as a real (if previously-masked) bug — confirmed live. Fixed by gating the status-bar push on a new s_gameplayActiveThisFrame flag (src/drawCommandStream.c), set whenever gameplay's own wall-tile draw (TILE_DRAW_FUNCTION_ENTRY_PC, DrawCommandStream_OnBackgroundDrawInstructionFetch) fires that frame and cleared every frame after being read — the same "does the wall-tile draw fire this frame" signal already used to invalidate the persistent logo cache above, since that function never runs outside gameplay either.
  • Wall-tile extrapolation (I key) shows small, random gaps between tiles in the Y direction — open, deferred. Reproduces identically on desktop and web, which points at the shared spriteStreamInterpolation.c extrapolation code rather than either renderer (confirmed the wall- tile branch, src/spriteStreamInterpolation.c lines ~453-503, is fully float end to end, and applies the identical additive delta to every wall-tile quad each frame, so a uniform shift alone can't desync tiles from each other — ruling out a simple "forgot to use float" bug). Two candidate mechanisms, not mutually exclusive, neither yet confirmed live: 1. FindSharedWallTileTopBandY only counts a top-band quad when q->y < 0.0f, strictly — since top-band Y is -fineScroll, this excludes the (recurring, once every 16px of scroll) case where fineScroll == 0 exactly, causing the whole wall-tile group to fall back to "held, not extrapolated" for that window while everything else keeps smoothly extrapolating. 2. The 3-real-frame wide-baseline path (mirroring the background mosaic's own dithered-scroll- speed fix — see the starfield bullet just above, and §3.3/§3.4.2 for the underlying dithered- register behavior) unwraps oldest relative to newer over a 2-real-frame span using UnwrapRelative(..., WALL_TILE_SIZE_PX) — i.e. assumes the true scroll distance over that wider window stays within ±8px (half of the 16px tile period). If the tile layer's actual scroll speed is fast enough, or dithers frame to frame the way the background's own scroll register is already documented to, the accumulated 2-interval distance could intermittently exceed that threshold, aliasing the velocity estimate by a full tile period for one held interval — mostly hidden by the tile pattern's own repetition, but revealing itself as a seam wherever the pattern doesn't perfectly self-align.

Deliberately left open for now — next step is a temporary live diagnostic (log newerTopBandY/oldestTopBandY/the computed delta each time the wall-tile branch runs) to distinguish between these before committing to a fix, rather than guessing. - HD atlas (R key) shows small cracks between wall tiles, both horizontally and vertically — open, deferred. Not a rendering-side bug: SpriteAtlasSlot's UV math is resolution-independent by construction (src/includes/spriteAtlas.h's own doc comment: "an .idx/.raw pair scaled together produces identical UVs regardless of which resolution is loaded"), and §11.3's background-scale bug (the one confirmed web-only rendering issue found this session) doesn't apply to wall tiles (SPRITE_QUAD_TEXTURED, not SPRITE_QUAD_BACKGROUND). The likely root cause instead lives in the atlas export pipeline (hatari_dotnet/), not this repo's rendering code: - hatari_dotnet/UpscaledAtlasExporter.cs upscales the entire packed 1x atlas image as one PNG (externally, by whatever tool produces the upscale source), then just rescales the index coordinates by the same integer factor — it does not upscale each tile independently. - But hatari_dotnet/Program.cs's packer only leaves _border = 1 pixel of gutter between packed sprites (baked into each index entry's stored X/Y by AtlasIndexWriter.cs), and that packing is purely by bin-packing space efficiency — two tiles that are adjacent on screen are almost never adjacent in the atlas. Any upscaler with a real kernel (bicubic, Lanczos, any AI/super-resolution model — anything except pure nearest-neighbor integer replication) has a receptive field wider than 1px at 1x, so a 1px border is very likely too thin to stop the upscale step from bleeding unrelated neighboring atlas content into what should be a tile's own clean edge pixels. Invisible at 1x (lossless nearest-neighbor sampling from source data, nothing blended); visible once the atlas is upscaled with any smoothing/interpolating method.

Likely fix direction (not attempted): increase _border in the 1x export (e.g. to 4-8px) so the padding survives the external upscaler's kernel radius, then regenerate the whole chain (1x atlas → external upscale step → UpscaledAtlasExporter) — cheaper and more standard than reworking the packer to place game-adjacent tiles next to each other (which the exporter doesn't have tilemap-layout knowledge to do today, and "which tiles are adjacent" isn't even fixed, since the same tile index is reused across different map contexts). Deliberately left open for now — the external upscale tool that produces UpscaledAtlasExporter's PNG input lives outside this repo, so confirming this diagnosis needs a regenerated 1x atlas with wider padding run through that same external step before it can be verified.

9. How to reproduce / verify

  1. Run Xenon 2 in Hatari far enough to populate g_drawCommandStream and trigger the one-shot spritecatalog.bin/stram.bin/palette.bin dump (open the "Masked Sprite" debug window once).
  2. Run hatari_dotnet against those files to (re)build spriteatlas_packed.png/.raw/.idx.
  3. Restart Hatari so SpriteAtlas_Load* picks up the fresh atlas; open the "Sprite Stream" debug window during actual gameplay with scrolling active.
  4. Expect: wall tiles and background scrolling smoothly (background visibly slower — parallax), ship/enemies/bullets on top, flash-tint working on ship-fire, no seams at the background's 192-line wrap boundary in either scroll direction.

10. This session's additions — sub-pixel coordinates, wall-tile fix, and a frame-capture

tool that surfaced a genuine ST hardware-timing finding

10.1 Sub-pixel coordinate precision, the corrected wall-tile fix, and the web wire-format

drift — see the plan doc

Three related fixes, fully written up in xenondoc/plan-atlas-upscale-and-frame-interpolation.md (its "Coordinate precision", "Wall tiles" addendum, and non-goals section) rather than duplicated here, since that doc already owns the Hatari-side rendering/interpolation implementation:

  • SpriteRenderQuad.x/y (renderFrame.h) widened from int32_t to float and the lroundf-to-integer rounding removed from spriteStreamInterpolation.c's extrapolation output — the interpolator was already computing continuous sub-pixel positions, they were just being thrown away before reaching a renderer.
  • Wall-tile extrapolation, revisited a second time: an initial "only the top row-band moves" model (reasoning from the original group-shift revert's own diagnosis, §3.2 above) turned out to be wrong — confirmed live via a temporary per-VBL diagnostic trace (screentrace.c, logging the topmost and first mid-band wall-tile quad's position every VBL) that the real (non-extrapolated) captured Y for both the top band and a representative mid-band row advance by the identical amount every real frame. The whole 16×16 tile grid genuinely scrolls as one, at constant speed; the game's three-row-band split exists purely so the original 68000 blitter can avoid manually drawing rows that would land off-screen — a real cost on that CPU, not evidence of non-uniform motion. Fixed by deriving one shared scroll delta from the top band's own -fineScroll-driven Y (the only unambiguous source, DrawCommandStream_ OnTileDrawInstructionFetch) and applying it uniformly to every wall-tile quad, regardless of band.
  • web/src/spriteQuad.ts had independently drifted out of sync with SpriteRenderQuad's wire layout (missing identityId, added in an earlier session for objectIdentity.c — see §4/§6 — reading 40 bytes where the struct is actually 44) — almost certainly meaning the web sprite- stream view was silently dropping every frame already, unrelated to the coordinate change. Fixed alongside the float conversion.

10.2 Frame-capture diagnostic tool (src/frameCapture.c / .h)

Built to answer, empirically, whether the frame-interpolation work (§8.1 note; full design in the plan doc) is actually producing smoother motion — a question that turned out to be hard to judge by eye alone (the debug window renders at only 2x scale, and a remote-desktop session's own refresh behavior was suspected to be masking the effect regardless).

Keeps a small rolling ring buffer (16 slots, FRAME_CAPTURE_RING_CAPACITY) of raw pixels from both render paths — the original/authentic SDL-rendered ST display (src/sdl/screen.c, Screen_Draw) and the SDL_GPU sprite-stream reconstruction (src/sdlGpuRenderView.c, RENDER_VIEW_SPRITE_STREAM) — and writes both to PNG on request, so they can be compared frame-by-frame in an external tool instead of relying on visual judgment through a remote session. Toggle keys C (start/stop capture) and F (flush current buffers to disk), polled the same unscoped way as the existing R/I debug keys (screentrace.c).

Deliberately zero-cost when disabled, no compile-time switch: SdlGpuRenderView_Submit branches on FrameCapture_IsEnabled() before doing anything GPU-readback-related — while off, the render pass targets the swapchain texture directly, exactly the code path that existed before this tool was added (no intermediate texture, no blit, no download, no fence wait). Only when capture is active does it lazily create an off-screen texture, render into that instead, blit it to the swapchain for normal display, and download it via a transfer buffer (blocking on a fence — simpler than cross-frame-pipelining the readback for what's an opt-in debug feature).

Output: <Configuration_GetScreenShotDir()>/framecapture/<YYYYMMDD_HHMMSS>/GGGGGG_SS_atari_ vTTTTTTTT.png / GGGGGG_SS_gpu_vTTTTTTTT.png, plus a short README.md written once per session directory explaining the naming scheme below for anyone browsing the output later. The session directory is named from local wall-clock time (not a raw tick/uptime counter), so separate play sessions sort oldest-first in a plain directory listing.

  • GGGGGG — the game's own frame number; the primary grouping key (see §10.3 for why the two streams need different raw values to describe the same real content).
  • SS — a small counter starting at 00, tracked independently per stream, counting that stream's own captures within this GGGGGG (needed because several GPU captures can share one GGGGGG while interpolation is on — one real position plus a few extrapolated in-between ones). This, not the VBL tick, is what makes a matching atari/gpu pair sort/interleave together in a directory listing — the raw tick is not comparable between the two streams for a matching GGGGGG (see §10.3), so using it as the sort key put a matching pair's files into two disjoint-looking tick ranges instead of next to each other.
  • vTTTTTTTT — Hatari's host-side VBL counter (nVBLs), kept as a trailing reference suffix only, for correlating exact real-time capture instants; it plays no role in sorting/grouping.

The filename prefix is atari, not sdl (which is what the internal C function/ring names still call it, since that accurately names the implementation): atari sorts alphabetically before gpu, so the reference/authentic image lists first within each matching GGGGGG_SS pair.

10.3 Confirmed: the ST's real double-buffering means the GPU reconstruction has strictly

lower display latency than the authentic path — not a bug

While validating the tool above, comparing an sdl and a gpu capture with the same naively- computed gameFrame tag (both read from DrawCommandStream_GetCurrentFrame(&g_drawCommandStream) at the same VBL) showed visibly different game state — enemies further along, wall tiles scrolled further in the gpu capture. Direct pixel comparison confirmed a clean, consistent one-game-frame offset: gameFrame=N's gpu output matched gameFrame=N+1's sdl output, every time, not occasional drift.

Root cause, confirmed via the reasoning behind Xenon 2's own known busy-wait-for-beam-position- then-swap idiom (the game waits for the shifter to reach approximately scanline 192, matching the already-documented row-band swap point, §3.2, before swapping its draw buffer and starting the next frame's drawing) cross-checked against Video_InterruptHandler_VBL's actual call order (src/video.c): the game's buffer-swap write (ST address 0x406, what DrawCommandStream_ OnMemoryWrite hooks) happens well before the VBL interrupt fires — there's a substantial gap between "beam reaches line ~192" and "beam reaches the true end-of-frame/VBL point." But on real CRT-scanned hardware, writing the new screen-base pointer doesn't retroactively change what's already mid-scan: the shifter keeps reading the old buffer for the remainder of the frame currently being displayed, and only starts reading the new buffer starting the next frame's scan-out. Hatari's own screen conversion (ConvST_DrawFrame, called from Screen_Draw, itself called from Video_DrawScreen inside Video_InterruptHandler_VBL — confirmed via direct read of that function, video.c) accurately reproduces this real hardware behavior, so at any given VBL it's still showing the previous game frame's content.

DrawCommandStream's frame counter has no such notion — it bumps the instant its capture hooks see the 0x406 write, i.e. the instant the game's CPU has finished computing a frame's content in memory, with no wait for a simulated CRT to actually get there. So the GPU sprite-stream reconstruction is not merely "different" from the authentic display path here — it is structurally lower-latency, by design of capturing draw commands directly rather than going through scan-out timing. Not a defect; worth remembering for any other latency-sensitive comparison against the authentic display path.

First fix attempt was itself slightly wrong. The obvious correction — tag every atari capture with gameFrame - 1 relative to the raw counter, uniformly — looked right at first (it did make whole matching runs agree), but direct pixel-by-pixel comparison of individual captures within a run exposed a finer structure: all of a run's captures agreed with each other and with the next run's first capture, except that run's own first capture, which was stale by one frame further back than the rest. A second attempt tried compensating with a two-tier rule (-1 normally, -2 on the tick right after a transition) and got the direction backwards — confirmed live, it made atari's tag describe content one frame newer than gpu's matching tag, the mirror-image of the original bug.

Actual fix, confirmed live: model it as a literal one-VBL-tick delay line on the raw counter, not a per-frame subtraction at all. Each atari capture is tagged with whatever DrawCommandStream_GetCurrentFrame-derived raw value was one call ago, not its current value (src/frameCapture.c, FrameCapture_PushSdlFrame, tracked via s_atariLastRawGameFrame). Since atari is pushed exactly once per VBL with no frame-skipping in play, "one call ago" and "one VBL tick ago" coincide, and expressing the correction this way makes the transition tick fall out of the same formula automatically — no special-casing needed, and it holds for every capture in a run, not just most of them. gpu (FrameCapture_PushGpuFrame) stays unadjusted throughout — it never needed correction, only atari did.

11. Web/WASM port of the frame-capture tool, atlas-resolution switching, and cross-backend

parity fixes

Follow-on session bringing the web build up to parity with §10's desktop-only work, plus several bugs this parity effort surfaced that turned out to be older/broader than the web port itself.

11.1 Frame-capture tool ported to the web build

§10.2's tool was desktop-only (HAVE_LIBPNG is undefined for the Emscripten build, and even if it weren't, Emscripten's virtual filesystem isn't visible to the user). frameCapture.c itself is platform-agnostic and already compiles for wasm (shared SOURCES, src/CMakeLists.txt); only its FlushToDisk PNG-writing path is desktop-only. New public accessors (FrameCapture_GetRawGameFrame, FrameCapture_ConsumeFlushRequested, FrameCapture_GetAtariSlotCapacity/GetAtariSlot) let src/webRenderBackend.c — the only new Emscripten-specific code this needed — poll the same ring buffer and atari-tag logic without frameCapture.c needing any EM_JS/emscripten.h of its own.

Three new EM_JS calls (JS_FrameCaptureSetEnabled, JS_FrameCapturePushAtariFrame, JS_FrameCaptureFlushComplete) bridge to a new web/src/frameCapture.ts, which: mirrors FrameCaptureRing_Push's ring/seqInFrame algorithm in TypeScript for the GPU side (no C-side GPU ring exists in wasm — FrameCapture_PushGpuFrame is desktop-only, called from sdlGpuRenderView.c, which isn't compiled into the web build at all); converts the atari ring's raw pixels from the ST display surface's native BGRX byte order (src/sdl/screen.c's sdlscrn, rm=0x00FF0000 etc., no alpha mask) to RGBA for canvas use; PNG-encodes every frame via the browser's own canvas.toBlob('image/png') (sidesteps the missing-libpng problem entirely — no C or TS PNG encoder needed); and assembles a hand-rolled STORED-mode (uncompressed) ZIP, offered as a click-through "Download capture" link rather than an auto-triggered download (browsers can restrict programmatic-click downloads outside a direct user gesture).

GPU-side capture itself (web/src/views/spriteStreamView.ts) reads pixels via gl.readPixels() called synchronously right after renderer.render() inside renderFrame() — required because the canvas's WebGL2 context has no preserveDrawingBuffer, so the buffer's a rendering's-worth stale by the time any other code could read it back. Gated on isFrameCaptureEnabled(), since readPixels forces a GPU sync stall.

11.2 Sprite-atlas resolution switching (R key) wired to the web renderer

The R key (§2.9/§6 mechanism — SpriteAtlas_SetActiveSlot, src/screentrace.c) only ever flipped a C-side global that sdlGpuRenderView.c (desktop-only) consulted; the web renderer (web/src/atlas.ts) loaded one fixed low-res atlas at startup and had no way to learn the C-side toggle happened at all, so R was a silent no-op in the browser despite both atlas resolutions (confirmed via live console log: SpriteAtlas_LoadIndex: ... spriteatlas_packed_hd.idx (atlas 2744x2740)) being loaded and available in the wasm virtual filesystem the whole time.

Fixed with the same edge-detection-poll shape already used for the frame-capture enabled flag (§11.1): webRenderBackend.c polls SpriteAtlas_GetActiveSlot() once per sprite-stream submit and fires a new JS_SpriteAtlasSlotChanged call on change (plus once unconditionally on the very first submit, so JS always learns which slot C actually started on). web/src/atlas.ts's loadSpriteAtlas() now takes a SpriteAtlasSlot and knows both assets' paths (spriteatlas_packed{,_hd}.{idx,raw}); spriteStreamView.ts's new setAtlasSlot() lazily loads and caches each slot (the HD atlas is ~16x the pixel count of the low-res one, not worth fetching until actually selected) and swaps the GPU-resident THREE.DataTexture in place.

11.3 Background quad V-coordinate scale bug in the web port (found and fixed)

Switching to the HD atlas visibly distorted the background mosaic (§3.3) in the web build only — comparing against the desktop reference (StreamVertex_AppendQuad, src/sdlGpuRenderView.c) showed the web port's appendQuad (spriteStreamView.ts) was missing the SpriteAtlas_GetActiveScale() factor (src/spriteAtlas.c — "how many atlas texels the active atlas uses per logical 320x200 source pixel") when converting the background's logical sourceOffsetY/wrapHeight into atlas-texel space. Invisible at 1x (scale == 1.0, a no-op), real at 4x. spriteStreamView.ts now tracks its own atlasScale (recomputed in applyAtlas from the newly-applied atlas's pixel height against the cached low-res one's) and applies it identically to the desktop formula. Wall tiles are unaffected — they're SPRITE_QUAD_TEXTURED, not SPRITE_QUAD_BACKGROUND, and don't go through this wrap-UV code path at all.

11.4 Web canvas/capture resolution was missing its presentation scale (found and fixed)

src/sdlGpuRenderBackend.c's RenderBackend_Create creates its actual SDL_Window at desc->width/height * scale (scale = 2 for everything except MaskedSprites/MemoryMap, which stay at 1) — SpriteStream's real desktop window/swapchain is 640x400, not the logical 320x200. src/webRenderBackend.c's RenderBackend_Create never had an equivalent: it forwarded desc->width/height straight through unscaled, so the web canvas — and therefore gl.readPixels-based capture, which reads canvas.width/height directly — stayed at raw 320x200. Symptom: GPU-side frame-capture dumps came out 320x200 while the atari dumps (driven by the real, correctly-scaled desktop-equivalent authentic display) came out 640x400, making them useless for a pixel-level side-by-side.

Fixed by extracting the scale rule into a shared RenderView_GetPresentationScale(RenderViewType) (src/includes/renderView.h), used by both sdlGpuRenderBackend.c and webRenderBackend.c now, so the two backends' scale can't drift apart silently again. Side effect: LowResVramView also gets scale=2 on the web build now (same rule desktop already applied to it) — confirmed safe, since its decode shader samples by normalized UV (vUV * vec2(320,200)), not raw pixel coordinates, the same resolution-independence property that makes this whole scaling trick work for SpriteStream.

11.5 Cross-backend build/behavior fixes found along the way

  • SDL_GetKeyboardState return type differs between SDL major versions — src/screentrace.c's shared R/I/C/F key-polling block (added this session) was written against SDL2's const Uint8*, which fails to compile against the desktop build's SDL3 (const bool*). Fixed with the same #if ENABLE_SDL3 branch src/sdl/screen.c already uses elsewhere in this file.
  • desktop_width/desktop_height could end up 0 under Emscripten's SDL2 port — Screen_Init (src/sdl/screen.c) calls SDL_GetDesktopDisplayMode to get a "don't exceed this size" ceiling for the window; Emscripten's port reports success but leaves width/height at 0 rather than failing outright (no real desktop to query), and the existing fallback only triggered on an actual failed call. That unguarded 0 fed straight into Screen_SetVideoSize's desktop- size clamp, forcing the authentic ST display window/canvas to 0x0 (confirmed live: SDL screen request: 640 x 436 (windowed) -> window: 0 x 0) regardless of any --borders/--statusbar/zoom setting — the actual root cause of border/status-bar command-line flags appearing to have no effect in the browser. Fixed by treating a reported zero size the same as a failed query, in both the SDL2 and SDL3 branches.
  • Opt_ParseParameters (src/options.c) aborts the whole parse on the first invalid option — every option listed after a failing one is silently skipped, with the error visible only in the browser's real devtools console (printErr), not the page's own diagnostic panel. Not a bug itself, but a footgun for src/emscripten.js's Module['arguments'] list — display-affecting flags (--borders, --statusbar, --frameskips) are now ordered first, right after --desktop, so a later option's validation failure (e.g. a preloaded-asset path issue) can't silently swallow them again.

11.6 Sprite-stream tile ordering and display size in the web debug-view list

web/src/bridge.ts originally appended every RenderView's tile to #hatari-render-views in ScreenTrace_Create call order (src/main.c) and squeezed every tile's CSS display size to a shared 420px width regardless of native resolution, so wildly different logical resolutions (320x200 up to the 1024x1024 memory map) stayed comparably legible on one page. Two changes: (1) the RENDER_VIEW_SPRITE_STREAM ScreenTrace_Create call now runs first in src/main.c, ahead of the legacy LOWRES_VRAM/MASKED_SPRITES/MEMORY_MAP debug traces, so the sprite-stream reconstruction — the primary view this whole effort is for — sits immediately after the authentic SDL-rendered display (#canvas) on the page, not buried among the debug traces; (2) SpriteStream is now shown at its native (§11.4-scaled) resolution instead of being squeezed into the shared 420px-wide comparable-tile sizing the debug views still use.

12. Desktop automation and real-frame observation

Desktop builds can enable the Xenon-specific loopback service with --xenon-control-port <port>. src/xenonControl.c implements a dependency-free framed TCP protocol for joystick-port-1 input, pause/run, stepping to the next genuine game frame, first-1-MiB STRam capture, full Hatari snapshot save/restore, and optional binary event logging. The standard-library reference client and decoder live in xenon_tools/.

Observation batches are finalized only by the write to $406, after Xenon's update and draw passes have completed. They are therefore emitted at the original game's approximately one-per-four-VBL cadence; spriteStreamInterpolation.c's renderer-only held/extrapolated VBLs cannot enter the automation stream. Each batch contains post-update object records (including updateProc, drawProc, proc3, sprite, coordinates, and the raw 98-byte MyObjectEntry), lifecycle events, the captured draw commands, palette, input, buffer pointers, timing metadata, and the current player shield ($cc6), lives ($cca), and displayed score ($d92..$d98). Protocol version 10 also embeds the compact $c60..$cd1 gameplay state. The raw envelope is 162 bytes: the exact 98-byte object plus 64 bytes from the current $4a motion-script cursor for $e1e/$e8a/$9a40/$503b4 objects (zero-filled for other update procedures). Consumers continue to interpret the first 98 bytes as MyObjectEntry; old 98-byte logs remain valid. The control trailer contains consumed input $a00, requested scroll $cda, horizontal steering $cde, ship/wall collision state $42e, applied scroll $cd8, the forward/backward camera limits $ce8/$cea, and the rolling backtrack-window size $cee. Its final six bytes contain the exact interstitial wait-for-fire Boolean, $a01 fire-edge latch, current zoom-script value, and script offset. Offline replay continues to accept earlier recordings. Protocol v10 prefixes the current control trailer with a variable-size list of exact player-hit events. $67D8 captures generic contact while A1 is still the colliding MyObjectEntry; $41DC captures a directional projectile while A0 still names it. Each event includes object/fixed identity, update and sprite procedures, D0 damage, source/player AABBs, scroll and cycle, eliminating end-of-frame proximity guesses for a collider that moves or is destroyed later in the same update. Live clients require protocol v10.

Protocol v6 also provides SET_FAST_FORWARD, a one-byte Boolean command that toggles Hatari's native fast-forward setting. It removes host real-time VBL/audio throttling; it does not change emulated CPU/video timing. record_run.py --full-speed enables it for a run and restores normal speed during graceful cleanup. Because autoplay uses STEP_GAME_FRAME, Hatari still stops at every canonical Xenon frame for the next controller decision instead of running ahead of the world model.

DrawMaskedSpriteEntry.identityId is resolved in the capture pusher rather than later in SpriteStreamFrame_Build, so the renderer, extrapolator, and automation stream all consume the same identity for a captured command even if its raw object-pool slot is recycled before rendering. Normal Hatari snapshots are accompanied by <snapshot>.x2meta, which stores the shared host-side identity namespace, slot mapping, and next ID plus a fingerprint of the snapshot's first 1 MiB of STRam and the real-game-frame counter. A matching sidecar restores identity continuity; a missing, corrupt, or mismatched sidecar starts a fresh namespace. Draw-command and extrapolation histories are always cleared after restore because their time baselines cannot safely cross a restored-state jump.

12.1 Sequential discovery of all level asset arenas

LevelArena_LoadOrRestore_FUN_00008fde uses $E1A as the one-based level number and caches the resident level at $8F8C. A new level is decompressed from the disk buffer at $3D800 into the fixed $31000-byte arena $4F000..$7FFFF; revisiting a previously retained level can instead copy the same-sized cached image. The arena starts with pointers for the background (+$00), tile map (+$04), tile graphics (+$08), palette (+$1C), and level frame thunk (+$24).

The compressed packages are intentionally followed in order. After decompression, writes at $90E8/$90EC store the sector and byte offset for the next eight-byte level-table entry. Consequently changing $E1A directly from level 1 to an arbitrary later level is not reliable: levels 2, 3, 4, and 5 must be loaded sequentially so each load discovers the next disk position.

Protocol v6 adds an opt-in asset-sweep state machine. It observes the game's loader call at $7EAA, pauses on the attachment-restoration call at $7EAE after the new arena is complete, and exposes a stable RAM-capture point. After the external tool saves that RAM, Hatari runs the original loader again from $7EAA with the next level number. It deliberately skips the post-load gameplay setup because the sweep restores its original snapshot afterward. This repeats through level 5, without running gameplay for levels 2–5 and without reimplementing the loader or decompressor. Disconnect, cancellation, and snapshot restore clear the state machine.

xenon_tools/capture_level_assets.py captures level 1 immediately, waits for the first normal transition, then writes full 1-MiB STRam images and metadata for levels 2–5. Each JSON file records both full-RAM and arena hashes, the arena header, named pointers, and the referenced 16-word palette. The utility preserves and restores the initial Hatari snapshot so this destructive level redirection is confined to the capture session.

12.2 Offline multi-level asset inventories and atlases

xenon_tools/build_level_atlases.py turns the five captures into complete per-level RSPR catalogues and invokes the existing hatari_dotnet decoder/packer. Discovery does not depend on objects being drawn during a playthrough:

  • ordinary level sprites are found as maximal chains of self-describing SpriteData headers in the arena before the tile map;
  • wall assets are parsed as the complete packed stream from header +$08 to the background mosaic. Index 0 is reserved, then 128-byte unmasked and 192-byte masked records follow; masked records are identified by the disjoint AND/background and OR/foreground bit invariant. Every signed map and composite-grid reference is checked against this parse, but is not treated as a complete inventory because animated wall frames may never occur in the static map;
  • the background is the 320x384 two-plane mosaic reached through header +$00;
  • {sprite,duration} animation scripts are accepted only when every frame resolves to a discovered or shared catalogue record; and
  • direct LEA table,A1; MOVEQ dimensions; JSR $E9C composite-grid preparations add tiles which are boss-only and therefore absent from the ordinary wall map. This finds the level-1 6x7 table at $509EE and a level-2 6x9 table in the second arena automatically.

The original RSPR catalogue contributes addresses outside $4F000..$7FFFF, covering shared ship/equipment/UI/shop/font data. Arena records are rebuilt independently for every capture. Parsing the full tile stream is essential: the older map-reference-only pass omitted 101 level-1 records, mostly animated wall frames, which made tiles blink as animation alternated between present and absent atlas entries. The desktop build must also restage regenerated atlases even when no executable object changed. The original native CMake integration attached copying only to the executable's POST_BUILD event, so an atlas-only rebuild could leave an older spriteatlas_level_01.* beside Hatari. A live post-shop trace exposed the exact failure: WallPlant_UpdateTileAnimation_FUN_0004f4ce cycles the right-wall plant through four 2x2 tile-code phases, but the stale staged index contained only phase 3's $063D52/$064652 variable tiles. The hatari-runtime-assets build target now has all runtime assets as explicit dependencies and uses copy_if_different on every requested build; it can also be invoked directly while iterating on externally generated atlases. Each generated level directory contains its arena records plus the 61 shared records requiring that level's palette; it deliberately excludes the 554 records already present in the fixed shared atlas. Every directory contains a PNG plus dependency-free SATX/SATL runtime files, an inventory, and the exact RAM/palette inputs used to render it. A top-level JSON/HTML report and contact sheet compare all levels.

The comparison confirms that an address-only global atlas is unsound: the loader deliberately reuses the same arena addresses for different bytes. Runtime integration should bind the fixed shared atlas and select the appropriate level atlas using $E1A/the resident-level marker (or an equivalent arena generation), retaining address lookup within that pair. The five gameplay palettes differ only at indices 1–3 in the current captures; indices 4–15 are identical. Indirect $E9C call preparations remain listed for diagnostic purposes, but private tables are no longer asset- coverage blockers because their graphics already belong to the complete sequential tile stream.

The 615 shared records can be partitioned without guessing from their rendered colors. The .NET exporter already selects fixed interstitial and shop palettes by address, covering 80 and 124 records respectively. Another 350 gameplay-palette records never use indices 1–3. These 554 records are therefore safe in one baked RGBA atlas. The remaining 61 records (58 four-pixel font glyphs and three masked sprites) use at least one changing index and require a palette-specific render. shared_palette_classification.json records the decision for every address, while the HTML report and shared_palette_variants.png show all five versions side by side. Each level cell also includes a red mask of rendered pixels whose RGBA value differs from the Level-1 reference.

The offline builder's default render path is also the runtime asset pipeline. The checked-in xenon_tools/UpscaleXennon2.chn workflow is executed with chaiNNer's headless run command and input overrides for the current output directory and PixelPerfect model. Its batch glob includes only the fixed shared atlas and five runtime level atlases, deliberately excluding the five comparison-only palette-variant atlases. After validating every result is exactly 4x, the .NET exporter converts the upscaled PNGs to SATL/SATX and the builder copies the six 1x/HD pairs to assets using spriteatlas_shared* and spriteatlas_level_01* through spriteatlas_level_05* filenames.

12.3 Runtime external-atlas rendering

The reconstructed renderer now treats the generated SATL/SATX files as its authoritative raster assets. SpriteAtlas keeps two physical banks for each resolution: the fixed shared bank and the active level bank. Lookups test the level bank first, so its palette-specific copies of the 61 shared records override the fixed bank; all other shared graphics fall back to the shared bank. The native GPU renderer binds both textures and writes a bank selector into every sprite vertex. Changing levels reloads and uploads only the level texture, while the shared texture remains resident. The web renderer follows the same two-texture and level-first lookup model and caches shared banks by resolution and level banks by (resolution, level).

The active bank changes at the post-load attachment-restoration call $7EAE, after the replacement arena is complete, rather than when $E1A first changes. Snapshot restore also reselects the bank from $E1A. The old monolithic spriteatlas_packed* files remain a startup fallback for incomplete asset deployments; they are no longer the normal runtime source.

The atlas exporter also writes an SMAP sidecar for every 1x atlas. It maps each source ST memory byte to a representative atlas pixel. The Masked Sprites diagnostic uses these maps for memory-read highlights and displays the shared and active-level atlases side by side. This keeps the diagnostic useful without rebuilding or decoding planar sprites in C whenever the window opens. The legacy C decoders remain only as a fallback when the external sidecars are absent and as useful validation code while the new asset coverage is being verified.

12.4 Level-4 chained-head boss overlay

The Level-4 boss is another example of why overlay addresses must be interpreted with the active level. Its initializer is near $505AA, but live objects store continuation addresses inside the routine as their update procedures:

Address Level-4 meaning
$5060C primary swing anchor update
$5072C primary chain-segment update
$50782 main head/core update
$507F6 damaging main-head face; calls player contact before copying the core anchor
$509DC secondary chain anchor update
$50A50 secondary chain-segment update
$50A60 destructible secondary small head
$50AEE destructible body-mounted small head (four instances)

The follower routines use a predecessor pointer at MyObject +$0E. Health is stored at +$32 on destructible heads. $504CA handles the secondary head and $504FE handles body-head damage; each death decrements the shared count at $D82. $50550 delegates face hits to the main head, while $50554 returns without damage until $D82 reaches zero. The required order is therefore five small heads first, main swinging head last. These labels, plate comments, and bookmarks are present in ReVa program /level4-live/mydumpat0.