# Space Taxi Mechanics (research notes from C64 disassembly) Source: VICE memory dumps in `stuff/spacetaxi/` decoded via the `dis6502.py` / `disLive.py` helpers. Full live-RAM listings are at `stuff/spacetaxi/live-level1.lst` (40,069 instructions) and `live-raw.lst` (titlescreen snapshot). This doc captures observed behavior in plain English to drive the JoeyLib port without dragging the original 6502 source into chat or comments. ## Memory map (post-load runtime) | Range | Contents | | ------------------- | ------------------------------------------------- | | `$0801-$1AFC` | Loader + initial data (looks data-like, not code) | | `$2800-$2FFF` | Custom charset (256 chars x 8 bytes mono) | | `$3000-$37FF` | Sprite bitmaps (referenced by $07F8-$07FF ptrs) | | `$4000-$6FFF` | Game code (physics, input, level logic) | | `$7000-$7DFF` | Game state globals + per-level data table | | `$CB00-$CFFF` | Music engine + tracker per-voice state | | `$CF52-$CFC0` | Custom IRQ handler | | `$0400-$07E7` | Screen RAM (40x25 char codes) | | `$D800-$DBE7` | Color RAM | ## Input handling - Per-frame routine starts at `$6032` (zeros velocity), reads `$DC00` at `$6040`, applies `EOR #$FF` so bits = 1 when pressed, stores processed mask to global `$7169`. - Bits in `$7169` after EOR: `0=UP 1=DOWN 2=LEFT 3=RIGHT 4=FIRE`. - At `$6056-$6065`, if screen-RAM sentinel `$07A6 == $7B` the dispatcher masks `$7169` to bit-4 only (menu mode, only fire matters). Otherwise gameplay path runs. - Gameplay path tests LEFT/RIGHT (bits 2+3) and UP/DOWN (bits 0+1) separately. **Fire is never tested in the gameplay path.** - The **fire button** is consumed by `$63DD` (a post-tick handler): edge-detect via `$71BD` latch. On press, toggles `$7197 bit 0` (cab anim parity) and triggers SFX from pointer `$6410` via `$42E9`. Likely the bonus-press to advance score. ## Velocity model (CORRECTED -- earlier "template velocity" note was wrong) The physics is **acceleration-based**, not constant-velocity. Stick input sets an instant per-frame *acceleration*. That acceleration adds to a persistent *velocity accumulator*, which adds to *position* each frame. Release the stick and velocity persists -- the cab drifts. Push opposite to decelerate. Gravity is a constant acceleration always applied to Y. ### Variables (named for what they actually hold) | Var | Meaning | | ------------ | ------------------------------------------------------------- | | `$7148/49` | Per-frame X acceleration (the "instant velocity" from input) | | `$714A/4B` | Per-frame Y acceleration | | `$714C/4D` | X velocity accumulator (16-bit signed) -- persists across frames | | `$714E/4F` | Y velocity accumulator | | `$7D91/92` | X-accel magnitude template (= 14 in level 1) | | `$7D8F/90` | Y-accel magnitude template (= 14) | | `$7D95/96` | X gravity (= 0 -- no horizontal drift) | | `$7D93/94` | Y gravity (= 1 -- constant downward acceleration) | | `$7D61/62` | Persistent X position, 16-bit fixed-point | | `$7D64/65` | Persistent Y position | | `$7D63` | 1-bit X fractional carry | | `$7185` | Mirror of `$7D63` | | `$7175` | Cached high byte of Xpos (= pixel column) | | `$717D` | Cached high byte of Ypos (= pixel row) | ### Per-frame algorithm (`$6032`, full trace) -- VERIFIED via emulator Verified by running `$6032` through the 6502 emulator with various joystick / sprite-ptr states. See `stuff/spacetaxi/trace.py`. ``` 1. Clear instant accel: $7148/49 = 0; $714A/4B = 0 [$6032-$603F] 2. Read input: $7169 = $DC00 EOR $FF [$6040-$6045] 3. Menu-mode test: if $07A6 == $7B: $7169 &= $10 (FIRE only); jump to vy [$6056-$6065] else: fall through 4. Horizontal accel gate (this branch was missing from my earlier write-up): if $7197 & 1 == 0: [$606F-$6076] (a) $7148/49 = $7D91/92 (xAccel template, default +) (b) if $7169 & $08 (RIGHT) : keep positive [$6088-$608A] (c) else (LEFT) : two's-complement negate [$608C-$60A2] if $7197 & 1 == 1: [$6190-$6198] $7169 &= $13 (mask out L/R bits) -- horizontal accel SUPPRESSED this tick 5. Vertical accel: [$60A4-$60D3] if $7169 & $03 (U/D): $714A/4B = $7D8F/90 (yAccel template) if UP bit set: negate 6. Integrate accel -> velocity: [$60D6-...] $714C/4D += $7148/49 + $7D95/96 (X vel += X accel + X grav) $714E/4F += $714A/4B + $7D93/94 (Y vel += Y accel + Y grav) 7. Integrate velocity -> position: $7D61/62 += $714C/4D (Xpos += X vel) $7D64/65 += $714E/4F (Ypos += Y vel) 8. Update caches: $7175 = high byte of $7D61/62 (pixel col) $717D = high byte of $7D64/65 (pixel row) ``` Verified single-tick results from emulator runs with level-A templates (xAccel=$0E=14, yAccel=$0E=14, xGrav=0, yGrav=1) and a zeroed velocity accumulator: | Input | $7197 | ax | ay | vx after | vy after | | ------ | ------ | --- | --- | -------- | -------- | | none | $C0 | 0 | 0 | 0 | +1 | | RIGHT | $C0 | +14 | 0 | +14 | +1 | | LEFT | $C0 | -14 | 0 | -14 | +1 | | UP | $C0 | 0 | -14 | 0 | -13 | | DOWN | $C0 | 0 | +14 | 0 | +15 | | RIGHT | $C1 | 0 | 0 | 0 | +1 | <- parity 1 suppresses X | RIGHT+UP | $C0 | +14 | -14 | +14 | -13 | **Asymmetric gating**: only X accel is gated by `$7197 & 1`. Y accel fires regardless. So when the sprite-ptr flicker bit is 1, the cab can still go up/down but not left/right. **When does the parity bit flip during gameplay?** Hot-grep for `EOR #$01 ... STA $7197`: - `$6A93/$6A95` -- only runs in collision-phase 1 (during death) - `$63FF/$6401` -- `fireButtonEdge`, fires on FIRE button rising edge `$619B` (called every frame in gated dispatch) WRITES `$7197` but PRESERVES the parity bit (`ORA $61` where `$61` holds the captured LSB). So during normal hover-fly gameplay, `$7197` parity changes only when the player presses FIRE. This means: in stretches of gameplay where FIRE isn't pressed, the parity bit is fixed -- so the cab either has horizontal accel every frame (parity = 0) or never (parity = 1). Effective horizontal acceleration rate depends on the live state of `$7197 & 1`. I have not yet traced what sets the initial parity bit on level entry, so the steady-state value during ordinary play is currently unknown. The 16-bit add helper at `$4072` is the integration step, called 12 times across the routine (vx+grav, +dx_accum, +Xpos and the Y mirror). Inputs at zero-page `$61-$64`, output back to `$61/62`. The fractional carry at `$6124-$6132` adds an extra +1 to the X step every other frame when the high byte's carry chain dictates -- gives sub-pixel granularity without floating point. ### Why the dump showed `dx_accum = 84`, `dy_accum = -66` At dump capture time the cab was mid-flight, drifting right (vel +84 sub-pixel-units/frame) and rising (vel -66 sub-pixel-units/frame in screen-coords where +Y is down). Confirms the velocities accumulate and persist across frames. ### Templates and gravity per level Per-level writers re-set `$7D8F-$7D96` at level init (`$5BD4`) so different levels can have different gravity / handling. Level 1 uses {X-accel 14, Y-accel 14, X-grav 0, Y-grav 1}. Different levels may have stronger gravity, sluggish accel, etc. The templates are saved on death (`$5A9E-$5ABC` copies `$7D8F-$7D96` into a cache at `$5CE9-$5CF0`) and restored on respawn (`$5BD1-$5BFE` copies the cache back). This is part of the trampoline disable/enable flow described in "Trampoline patching" below. ### Physics is called from 3 places `$6032` is a generic motion subroutine called from: - `$5F45` -- main gameplay (gated by `$7164 == 0` = no collision dispatch) - `$5C2C` -- death/fall animation loop (exits when `$717D == $D4` = row 212) - `$5C91` -- takeoff animation loop (exits when `$717D < $87` = row 135) Same motion math is reused; only the exit condition differs. ### Screen-edge behavior -- BOUNCE, not wrap (`$6AED`) `$6AED` reflects X-velocity at the screen edges instead of wrapping or stopping. Two branches: ``` $6AED if $7185 == 0 (low fractional, going left): if vx-hi ($714D) < 0 AND col ($7175) < $17 (= 23): negate ($714C/$714D) ; EOR #$FF, INC for two's complement $6B17 else if $7185 != 0 (going right): if vx-hi ($714D) >= 0 AND col ($7175) >= $41 (= 65): negate ($714C/$714D) ``` So crossing column 23 going left, or column 65 going right, inverts vx. The cab bounces off the screen edges like a bumper. JoeyLib port currently clamps with vx=0 -- needs updating to match. ## Per-level data table (`$7D00-$7D60`) Per-level data lives in this 96-byte region, written by the level loader (the `$6283` bank-switch wrapper calls into `$9656` which runs in all-RAM mode and reads level data from RAM beneath the Kernal/I/O area). Layout derived from readers: | Offset | Use | | ------------ | ---------------------------------------------------- | | `$7D09` | Special-pad sentinel: $6E2E branches when active | | | pad index ($7150) matches this | | `$7D0A,X*8` | Pad table base. Each slot is 8 bytes: | | `,X+0` | pad data E (read by $645C / $67E3 setup) | | `,X+1` | pad data F | | `,X+2` | pad-X bound2 high | | `,X+3` | pad-X bound2 low | | `,X+4` | pad-X bound1 high | | `,X+5` | pad-X bound1 low | | `,X+6` | pad row (compared with $717D = taxi row) | | `,X+7` | (reserved / decorative?) | | `$7D5A` | Pad count (number of slots used in the table) | | `$7D5B-$5D` | Taxi spawn X position: lo, hi, frac | | `$7D5E-$5F` | Taxi spawn Y position: lo, hi | | `$7D60` | Fuel max (or initial pad-cell count) | ### Pad detection (`$645C`) Gated routine, called every frame from the post-tick chain. Only runs when `$7150 == 0` (airborne) and the anim-parity bit is set and Y velocity high byte is zero (slow descent). Then: ``` X = $7D5A (pad count); decrement X loop: if $717D (taxi row) != $7D0E,X*8 (pad row): try next pad set $61/$62 = $7D62/$7D63 (taxi Xpos) set $63/$64 = $7D0B,X*8 / $7D0A,X*8 (pad X bound) JSR $6565 (16-bit compare); if Xpos < bound1, skip set $63/$64 = $7D62/$7D63 again (taxi Xpos) set $61/$62 = $7D0D,X*8 / $7D0C,X*8 (pad X bound2) JSR $6565; if bound2 < Xpos, skip $7150 = $7151 = X+1 ; mark on-pad $71CD = 0 compare X+1 to $7D8E (active sprite idx); if equal, jump $64F8 ; (continuation handles death-counter, music, etc.) ``` So pad landing is detected by **row match** + **column bounds overlap**, indexed off the per-level pad table. The result is stashed in `$7150` (1-based pad index, 0 = airborne). ## Game state sentinel `$07A6` is the top-right cell of screen RAM. Used as a one-byte state tag distinguishing menu mode (`$7B`) from gameplay. Many input/handler sites branch on this value. Title/instruction/score screens write `$7B` there; level start writes something else. ## Crash / pad / passenger systems (mostly traced) The original game leans entirely on VIC-II hardware collision registers, no per-cell screen-RAM walking: - `$D01E` = sprite-to-sprite collision latch - `$D01F` = sprite-to-background collision latch (reading either also clears the latched bits) Three call sites use them: | Site | Role | | ------- | ---------------------------------------------------- | | `$6920` | clear-only (reads to reset latches, JSRs anim tick) | | `$6946` | death/transition: `STA #$00 -> $D015` (sprites off), | | | clear collision/menu/animation state | | `$6966` | per-frame dispatch: latch both regs, then jump to | | | `$6A72`/`$6B24`/... based on `$7164` phase | The phase byte `$7164` is the dispatcher; `$7165/$7166` form a frame-rate gate (count down each frame, reload when zero) so each phase handler runs every N frames, not every frame. ### Collision dispatch trace (VERIFIED via emulator) Verified by running `$6966` from `Tracer.from_dump('raw.bin')` with hand-set `$D01E`/`$D01F` values and observing the resulting `$7164` phase and `$7197` cel transitions. | `$D01E` (ss) | `$D01F` (bg) | Phase after | `$7197` | `$721D` | Outcome | | ------------ | ------------ | ----------- | ------- | ------- | ----------------- | | $00 | $00 | 0 | $C1 | - | no change | | $00 | $01 | **1** | **$CC** | $01 | DEATH | | $09 | $00 | **1** | **$CC** | $01 | DEATH (passenger 3 + sprite 0 collision) | | $09 | $01 | **1** | **$CC** | $01 | DEATH (bg wins) | | $08 | $00 | 0 | $C1 | - | no change (sprite-sprite without sprite 0 bit) | **The $7D75 trampoline is STATIC** in both `raw.bin` (under-ROM capture) and `mem0000-level1.bin`. The bytes are `$4C $9A $7D` (JMP `$7D9A`) where `$7D9A` is `$60` (RTS). So the trampoline ALWAYS returns A unchanged. Grep across the entire disassembly finds NO writers to `$7D76` or `$7D77`. The "trampoline gets patched to allow pickup" theory I had earlier (in an earlier draft of this doc) was wrong -- the operand is fixed. Demonstrated mechanism via emulator: manually plant `LDA #$00; RTS` at `$7DA0` and patch `$7D76/$7D77` to point there. After: | Trampoline target | After taxi+passenger collision | | ------------------ | ----------------------------------- | | `$7D9A` (RTS) | phase=$01, sprite $C1->$CC, DEATH | | `$7DA0` (LDA #$00) | phase=$00, sprite $C1, no death | So the mechanism EXISTS but isn't used in any state we've captured. The RTS-without-death path at `$69B3` requires `$71CA` bit 1 (sprite 1 = flame) ALSO set, which is a multi-sprite collision involving cab + flame + passenger. Possibly that's how pickup is detected -- the C64 game arranges for the flame sprite to be involved in a 3-way collision when boarding. Not yet verified. For the JoeyLib port: my explicit pickup logic ("if cab is landed on the passenger's pad, board them") is a clean simplification. The C64's mechanism via 3-way sprite collision is too entangled with hardware-specific behavior to reproduce directly. ### Phase 1 handler (`$6A72`) -- fall + thrust SFX This handles in-flight gameplay. Each tick it: 1. Reads `$721D` -- if high bit set (BMI taken), jumps to the phase-advance / clamp branch (player is dying). 2. Otherwise decrements `$721B` (thrust freq sweep accumulator), shifts it right one bit, writes both copies to `$D400/$D401` (voice 1 frequency lo/hi). This is the **thrust hum** -- voice 1 freq decrements over the course of a thrust burst. 3. Toggles `$7197` bit 0 -- frame-parity flag for visual sync. 4. Integrates `$714C/$714D` (per-frame dx) into `$714E/$714F` (sub-pixel row accumulator), +40 per frame. 5. Adds the integrated row delta into `$717D` (taxi pixel row). 6. If `$717D >= $DA` (218), clamps it and **INCs `$7164`** -- advances to phase 2 (the floor/pad has been reached). 7. Falls through to a screen-edge wrap routine at `$6AED` that inverts `$714C` when the taxi crosses column 23 (`$17`). ### Phase 2 handler (`$6B24`) -- death sprite-cel walk After phase 1 clamps and INCs `$7164` to 2, this handler runs: ``` DEC $7165, BEQ continue, else RTS ; frame-rate gate INC $7166 ; slow the rate over time $7166 -> $7165 ; reload gate INC $7197 ; advance sprite-0 cel ptr LDA $7197; CMP #$D1; BEQ advance ; reached final death cel? RTS ; otherwise stay in phase 2 advance: $718E = 0 INC $7164 ; -> phase 3 $7165 = $46 (70 frames) $68/$69 = $6DDF (death art pointer) JSR $42E9 (load death SFX/art) ``` So phase 2 walks the sprite-0 pointer from `$CC` through `$D0` (death animation cels at `$3300, $3340, ... $3400`), one cel per N frames where N grows over time (slowing-down "fall apart" effect). When it hits `$D1`, transitions to phase 3. ### Phase 3 handler (`$6B4C`) -- pause, lives--, respawn or game-over ``` DEC $7165, BEQ continue, else RTS ; 70-frame pause LDA $721C BEQ $6B64 ; not in menu state, continue PLA PLA ; drop our return + caller's return LDA $5E9D BNE $4955 (exitToTitle) JMP $5EC4 (gameInitContinue) ; restart gameplay ``` Otherwise (gameplay state): ``` LDA $7163; CMP #$05; BNE $6BBA ; death-stage gate JSR $43A5 (hudInit) LDA $715C (lives); BEQ $6BA1 ; out of lives -> game-over LDX $716D (active dying slot) $7152,X = 0 ; blank slot DEC $715C (lives--) LDA $7D8E; CMP #$0B ; was it the "main" slot? BNE $6B96 ; not -> continue alt branch $0412/$0413/$0414/$0415 = $67 ; rewrite HUD chars JMP $6BA1 (game-over path) ``` The respawn path at `$6BBA` decrements `$71CF,X` (per-passenger counter), if zero jumps to `$6BD0` (post-death-transition); else patches HUD char and JMPs `$5F27` (mid-prelude re-entry). `$6BD0` (post-death-transition): JSR `$704E`, JSR `$5FBB`, INC `$71CE` (fare counter), compare to `$7213`; if all fares delivered, JMP `$602F` (level complete); else JMP `$5F02` (main loop re-entry). So the death pipeline is: - Sprite-bg hit -> $6A30 starts phase 1 - Phase 1 -> cab continues physics, BMI on $721D drives to phase 2 - Phase 2 -> animates sprite-0 cel forward $CC..$D1 (death anim) - Phase 3 -> 70-frame pause, then DEC $715C, blank slot, transition ## Main game loop (`$5F18-$5FB8`) Falls through from gameInit (`$5EC1`) on level entry via `$6FDC: JMP $5F18`. Tight loop, **no per-frame wait** in the JMP back -- music IRQ ticks asynchronously, but `$5F94`/`$5FA3` calls to `$404B` (VBL wait) provide explicit raster sync mid-loop. ``` $5F18 prelude 8 JSRs (level-entry one-time setup): $61FB advanceFareSlot $4248 spriteShadowInit $6946 spritesOffReset $7D66 stateVec0 (RTS stub on level1) $6F18 takeoffSetup $6888 taxiSpawnInit $7D69 stateVec1 (RTS stub on level1) $6906 framePresent $5F40 loop top Gated dispatch: 9 JSRs guarded by LDA $7164 (collisionPhase); BNE skip. Skipped during death animation: $6032 physicsTick $63DD fireButtonEdge $6D6A flameSpriteUpdate $645C padDetect $619B taxiSpriteCelSelect $6BE7 levelEndCheck $6DFF padLandingBob $6E23 padPaymentAnim $61BD passengerEventDraw $5F88 post-work Unconditional JSRs (animation, sprite updates, scoring, etc.): $67E3 postTickStateGate $65C1 deathStageDispatch $7D6C stateVec2 $4253 spriteShadowMarshal $404B vblWait <- explicit VBL $4293 spriteShadowFlush <- now in vblank $63D0 bitScrollRight $7D6F stateVec3 $6419 fuelBarHud $404B vblWait <- second VBL sync $6966 collisionDispatch $43E5 hudDraw $4320 sfxEnvelopeTick $4FCB runStopWatcher $70B1 passengerArrTick $4BF8 (dead -- single-shot DDR setup, fall-through) $5FB8 JMP $5F40 loop close -- repeats dispatch + post-work. Prelude is one-shot on entry. ``` The two `$404B` VBL waits straddle the sprite flush so screen tear doesn't happen during the shadow-to-hardware copy. The single entry to `$5F18` is `$6FDC JMP $5F18`. Three sites in the init chain jump to `$48AD` instead -- that's the **menu / title state** alternate path. ### State-vector dispatch (`$7D66-$7D75`) Six adjacent 3-byte slots, each holding `JMP abs`. The main loop calls into them via `JSR`, so the slot's `JMP abs` operand acts as a function pointer. **Trampoline patching mechanism**: state transitions don't patch the JMP operand bytes (`$7D67/$7D68`, etc.) -- those are static. What gets patched is the OPCODE byte (`$7D66`, `$7D69`, etc.): - Writing `$60` (RTS) to the opcode byte = trampoline disabled, whole slot is a no-op `JSR `. - Writing `$4C` (JMP abs) = trampoline enabled, jumps to whatever pre-baked operand follows. `$5AC1-$5AD0` is the **trampolines-off** patcher (writes #$60 to all six opcode bytes), `$5C03-$5C12` is **trampolines-on** (#$4C). These bookend the death-and-respawn sequence: shut everything off, play the death anim with most main-loop work neutered, then re-enable for respawn. The JMP targets in level 1's gameplay snapshot (`mem0000-level1.bin`): | Slot | Called from main loop | Target | Role | | ------ | --------------------- | --------------- | --------------- | | `$7D66`| prelude ($5F21) | `$7D9C` (RTS) | post-prelude 0 | | `$7D69`| prelude ($5F2A) | `$7D9B` (RTS) | post-prelude 1 | | `$7D6C`| post-work ($5F8E) | `$7D9D` (RTS) | per-tick 2 | | `$7D6F`| post-work ($5F9D) | `$7D98` (RTS) | per-tick 3 | | `$7D72`| `$6283` decompress | `$7D99` (RTS) | level-init hook | | `$7D75`| hit dispatch ($6A03) | `$7D9A` (RTS) | passenger-hit verdict | On the title screen (`mem0000.bin`/`mem0000-title.bin`), some of the operands point at real routines (`$7E5A` for slot 2, `$7E57` for slot 5). Those operands are loaded as static data during the title-screen boot, not patched by code we've found. ### The `$7D75` hit-dispatch trampoline (resolved) Called from `$6A03` with `A=#$01`: ``` $6A01 LDA #$01 $6A03 JSR $7D75 ; trampoline -- may modify A $6A06 STA $721D ; player-state byte $6A09 LDA $71CB $6A0C AND #$01 ; taxi-vs-background? $6A0E BNE $6A1E ; -> death start $6A10 LDA $5569 $6A13 BEQ $6A16 $6A15 RTS ; menu-mode-ish gate $6A16 LDA $721D $6A19 CMP #$00 $6A1B BNE $6A1E ; -> death start if trampoline said die $6A1D RTS ``` So the trampoline's role is to **decide whether a passenger contact should kill the cab**. The default `$7D9A` target (`RTS`) leaves A=1, so $721D=1, so the BNE at $6A1B IS taken -- passenger contact without further state would crash the cab. That's actually consistent with the original behavior: random passenger collisions DO kill you; you have to be on a pad in the right state for them to board. When the per-level setup wires a different target (e.g. `$7E57`, `LDA #$00; RTS`), A=0 after the trampoline, $721D=0, BNE not taken, passenger contact is benign. This must be triggered during the pickup window (passenger on top of cab while landed on right pad). JoeyLib port doesn't need this trampoline scheme -- our pickup logic lives explicitly in `stPassenger.c`. ### `$7D72` level-init hook Called only via the `$6283` bank-switch wrapper. Probably loaded with a per-level "post-load setup" target by whatever generates the per-level table. On gameplay-state dumps it's `RTS`. ## Title / menu state (`$48AD`) Entry from `gameInit` (`$5EC1`) when `$5E9D != 0` (post-mortem) or `$721C != 0` (menu mode requested). ``` $48AD $7213 = 1 ; one fare needed $721C = 1 ; menu mode $7214 = 0 $7171/$7172 = 1 ; menu timers $3F/$40 = $0900 ; script buffer $5569 = 0 ; clear paused flag $595C = $20 $5975 = $2A if $5E9D: $0900/$0901 = $00 $FF JMP $5F02 (mainEntry) else: $3F = 2 JMP $5F02 ``` After the JMP to `$5F02`, the main loop runs with `$721C=1`, which causes the post-tick chain to behave differently (menu-only paths in $4FCB, $6BE7). The actual title-screen DRAWING happens via `$4525` (title sprite setup) and `$4861` (logo color cycle). `$4525` puts all 7 sprites at the same screen corner ($AA, $E4 / $8C), sets their ptr to `$DA` ($3680), color to 7, then triggers song 8 via `$CB02`. This is the **idle title state**. `$4955` is the reverse: gate-off voice 1+2, noise off, load song from `$6F75,X` (X = $716B current music index), set $5E9D=0, JMP $5EC4. **Exit-to-title cleanup**. ## Lives + death state | Var | Meaning | | --------- | ------------------------------------------------ | | `$715C` | **Fare-slot count** -- INC at `$6559` on each new pad-hover setup, DEC at `$69CA`/`$6B7B`/`$6B9E` on death-finalize, zeroed at `$634A` on scene load. Earlier draft labeled this "lives counter" based only on the DEC sites; the INC and scene-load-zero behavior rule that interpretation out. Probably tracks "fares currently in play". The port's own lives mechanic does not derive from this address. | | `$7163` | 10-stage death-anim state machine ($65C1 dispatch) | | `$7164` | 4-phase collision dispatch (0/1/2/3) | | `$7167` | Death dispatch in-progress gate | | `$7152,X` | Per-sprite slot table; cleared on death | | `$7D8E` | Index into `$7152` for the active death subject | | `$71CE` | Successful-fare counter; compared with `$7213` | ## Death-stage dispatcher (`$65C1`) Called every frame from post-tick. 10-way jump on `$7163`: | Stage | Target | Purpose | | ----- | ------- | ---------------------------------------- | | 0 | $660B | idle RNG-gated stage advance ticker | | 1 | $665F | stage-1 handler | | 2 | $66B7 | stage-2 handler | | 3 | $66DA | stage-3 handler | | 4 | $66DD | stage-4 handler | | 5 | $6739 | "takeoff" -- pad hover with input | | 6 | $6742 | stage-6 handler | | 7 | $67A3 | stage-7 handler | | 8 | $67A6 | stage-8 handler | | 9 | $67C6 | stage-9 handler | Stage 0 idle ticker: `$71CC` decrements every frame; on hitting 0, runs `$6537` (probability gate), reads pad-table entry at `$7D0E,X*8` (the X-of-pad table) into hover positions `$7186/$7176/$717E`, sets `$718F=1` (hover active flag), `$7163=1` (advance to stage 1), `$715D = $715E`. So death-stage 0 is the **between-level pause** ticker that picks a random next pad and transitions into the takeoff sequence. ## Per-tick handlers detailed ### `$67E3` -- postTickStateGate State-dependent setup that runs every frame. Reads `$7150` (active pad). If 0 (airborne) RTS. If non-zero, dispatch by `$7163`: | Stage | Action | | ----- | ----------------------------------------------------- | | 0,1,3,6,7 | RTS (idle) | | 2 | INC `$7163`, save current col/frac to `$7160/$715F`, fall to $6808 | | 5 | If $716F=0 (bob timer done): INC `$7163`, `$718F=1`, | | | save col/row, `$7198=$CB`, $715D=$715E, build | | | takeoff art pointer from `$7D0F,X*8`/`$7D10,X*8` | | $6808| `$7167=1`, JSR `$6866` (pad-passenger-on colors) | The "$6866 vs $6877" pair is the **pad indicator color flash**: $6866 writes `$0B` to `$DBB4/$DBB5` (left edge) and `$02` to `$DBDC/$DBDD` (right edge); $6877 swaps them. Toggled by stages. ### `$619B` -- taxiSpriteCelSelect ``` LDA $7197; AND #$01 -> $61 ; isolate frame-parity bit LDA $716A; AND #$04 -> Z? ; right input? BNE -> A=$DC ; flame-on base ptr LDA $716A; AND #$08 ; left input? BEQ RTS ; no input -> keep current ptr A=$C0 ; (some other base ptr) ORA $61 (parity bit) ; mix in the flicker bit STA $7197 ; new sprite-0 ptr ``` So the cab's sprite ptr is selected as base ($C0 or $DC) OR'd with the per-frame parity bit. Base $DC = "flame-on" cel (with engine exhaust visible), base $C0 = "flame-off" cel. The +1 flicker is the EOR #$01 from phase-1 handler at $6A95, which alternates the exact cel address. NOTE: the C-port doc said "2 cels"; that's actually 4 cels organized as 2 bases x 2 flicker variants. ### `$61BD` -- passengerEventDraw If `$716F != 0` (a passenger event just happened): peek `$71CD` (event type) and dispatch: | `$71CD` | Event | Pointer source for $6C7F | | ------- | ----------- | ---------------------------------- | | 1 | (drop-off?) | $6A/$6B = $6C6C | | 2 | (pickup?) | $6A/$6B = $6C7A | | other | (takeoff?) | $6A/$6B = $6C7D | `$6C7F` is "render text/scoreboard": marshals shadow, flushes, then draws indirect text. So this routine surfaces a HUD blurb based on what just happened. ### `$63DD` -- fireButtonEdge ``` LDA $7150; BNE RTS ; only when airborne LDA $71BD; BNE -> store fire ; if previously fired, latch update LDA $7169; AND #$10 ; FIRE bit BEQ RTS ; not pressed -> done STA $71BD ; new latch = $10 EOR $7197 #$01 ; flicker cab sprite $68/$69 = $6410 ; SFX pointer JSR $42E9 ; play SFX ``` So fire-button is debounced; on the rising edge, plays a SFX from `$6410` and flickers the cab sprite. This is likely the **bonus-fare or quick-stop bell**. ### `$6D6A` -- flameSpriteUpdate ``` LDA $716A; BEQ -> turn off ; no input held -> noise off LDA #$81; STA $D412 ; voice 3 noise ON CLC LDA $7175; ADC #$FE -> $7177 ; flame col = taxi col - 2 LDA $7185; ADC #$FF -> $7187 ; flame col frac with borrow LDA $717D; STA $717F ; flame row = taxi row LDA $716C; EOR #$01 -> $716C ; parity toggle BEQ -> $6DAA ; on every other frame LDA $716A; TAX LDA $6DB0,X -> $7199 ; sprite-1 ptr = (per-direction cel) $7190 = 1 ; flame visible RTS $6DA5 (no input): A=$80; STA $D412 ; voice 3 OFF $7190 = 0 ; flame hidden ``` So while input is held, sprite 1 is repositioned to (taxi col - 2, taxi row) and pointed at a per-direction cel from table `$6DB0`. Plus voice 3 plays noise (jet sound). When input released, sprite 1 disappears and noise gates off. **Flame is a separate sprite (not just a color flash).** The `$6DB0` table is the **engine-flame cel pointer table** (direction-indexed, 8 entries). ### `$6E23` -- padPaymentAnim Per-frame check of pad payment HUD: ``` LDA $7150 CMP $7D5A ; on last/sentinel pad? BEQ $6EAA CMP $7D09 ; on special pad? BNE $6E36 LDA $07B1 ; HUD char at $07B1 CMP #$66 BNE RTS ; only animate if char is currently $66 $68/$69 = $07C2 (HUD area) JSR $440B ; draw -> sets carry on overflow BCC RTS INC $71C8 ; tick mod-8 AND #$07 STA $71C8 BNE -> check stage ; not at boundary $68/$69 = $6DBB ; SFX ptr JSR $42E9 (SFX) JMP $6E77 ``` So $6E23 cycles the **fare-payment HUD chars** at $07C2 area every 8 frames, plays a "ka-ching" SFX from $6DBB on each boundary. ### `$6DFF` -- padLandingBob Misaligned in the disassembly (the byte at $6DFE eats it), but the real entry is at $6DFF: ``` LDA $716F BEQ RTS ; timer idle DEC $716F LDA $716F AND #$01 BNE up ; odd value -> row up INC $717D ; row down (even) LDA $716F BEQ end-of-bob RTS end-of-bob: $7167 = 0 ; clear death-in-progress JSR $6877 (pad color alt) RTS up: DEC $717D ; row up RTS ``` So `$716F` is the **pad landing bob timer**. While it counts down, the row alternates +1/-1 every other tick. On reaching 0, clears the death-in-progress gate and swaps pad colors. ### `$6EF0` -- padLightingGate Only acts during `$7163` stages 3, 7, or 8. If `$7150 != 0` (on a pad): ``` LDA $71CA; AND #$02 STA $7167 ; persistent "current bg-coll" flag BEQ $6F14 -> $6877 (pad color "off") JSR $6866 -> pad color "on" ``` So when a passenger sprite touches the cab and the cab is on a pad, the pad lights up. This is the **passenger boarding indicator**. ### `$6BE7` -- levelEndCheck ``` LDA $028D (keyboard scancode) CMP #$04 BNE $6BF3 LDA $5569 (paused flag) BNE $6BFB (force end) $6BF3: LDA $7D65 (Y position hi byte) CMP #$1B BCC $6BFB ; cab is at/above spawn row -> end level RTS $6BFB: PLA PLA ; abandon return chain $D412 = $80 ; voice 3 OFF $D404 = $D40B = 0 ; voice 1+2 control off $7216 = $7217 = 0 ; envelope timers cleared JSR $704E LDA $721C BNE -> JMP $6B52 (level transition) JMP $5A11 ``` `$028D` is the C64 keyboard scancode buffer. Bit pattern $04 might be the F1 key or Q. So this is a **manual abort path** plus a **successful-level-finish check** (cab reaches the top of the screen, row < $1B = 27). ### `$70B1` -- passengerArrTick ``` LDX $7214 (active fare idx) LDA $7223,X; BNE RTS ; per-passenger arrival timer not zero LDA $07C3 (HUD char); CMP #$6D; BCC RTS LDA $71CF,X; ADC #$01 ; advance per-passenger counter STA $71CF,X STA $7223,X TAX LDA #$C8 -> $0798,X ; mark slot complete LDA #$04; JSR $44CF ; trigger anim RTS ``` So per-passenger HUD slot animation; when the counter reaches a threshold, marks the slot complete and triggers a sprite anim tick. ### `$4FCB` -- runStopWatcher Per-frame keyboard check: ``` LDA $028D AND #$01 ; RUN/STOP bit BEQ $4FED ; not held $D412 = $80 ; voice 3 off $7222 = $80 ; mark thrust ended $D40B = $D404 = 0 ; voice 1+2 ctrl off LDA $CB81 ; voice 1 track ptr BEQ -> JMP $4FCB ; spin until music done JSR $44E1 ; wait IRQ tick JMP $4FCB $4FED (no RUN/STOP): LDA $5E9D; BEQ -> further check RTS (check $721C and $DC00 for menu interactions) $4FF8: JSR $FFE1 (kernal RUN/STOP check) -- if pressed, transition ``` So $4FCB is the **abort to title / pause** handler. RUN/STOP key kills all audio, then either loops waiting for release or transitions. ### `$6419` -- fuelBarHud ``` LDA #$0B; STA $DBA2,$DBA3,$DBCA,$DBCB ; pad color cells INC $71BE; AND #$07; STA $71BE ; mod-8 ticker CMP #$05; BPL RTS ; only first 5 of 8 frames LDA $714F (Y-vel hi); BMI down ; sign-test BEQ check_X ; Y vel = 0 A=$02, X=$28; JMP write check_X: LDA $714E BMI down A=$03, X=$00; JMP write down: A=$07, X=$00 write: STA $DBA2,X; STA $DBA3,X; RTS ``` So the fuel bar's color depends on cab Y-velocity sign (rising vs descending vs hovering). The `$DBA2-$DBCB` cells form the in-flight indicator strip. ## IRQ handler (`$CF52`) -- music only, no animation The IRQ does **music ticks only**. Sprite multiplexing, screen-cell animation, elevator updates, etc. happen in the main game loop, not the IRQ. The IRQ structure: ``` $CF52: save A/X/Y via self-mod into the LDA/LDX/LDY immediates at $CF9C-$CFA1 (cheaper than stack push) $CF5B: BIT $CBFF -- pull bits 7 and 6 into N and V flags $CF5E: BMI / BVS branching: bit 6 set -> skip music (frame-done guard) bit 7 set, V=0 -> $CC28 (full silence/reinit all voices) bit 7 clear, V=0 -> $CC58 (soft gate-off) $CF6D: save $FC/$FD into $CBD4/$CBD5 $CF77: loop X = 0, 7, 14 (the three SID voices) $CF7D: JSR $CC6C (per-voice tracker tick) $CF80: $CBD6 += 7; if < 21, loop $CF8A: restore $FC/$FD $CF94: OR #$40 into $CBFF (mark frame done) $CF9C: LDA/LDX/LDY (self-modified) restore regs $CFA2: JMP $EA31 (KERNAL standard IRQ exit) ``` ### Music tracker per-voice tick (`$CC6C`) The 3-voice loop driver. Each call gets `X = 0` (voice 1), `7` (voice 2), or `14` (voice 3) so the same code services all three SID voices. Per-voice state lives in `$CB80+X` slots: | Var | Role | | ------------ | -------------------------------------------- | | `$CB80,X` | track pointer lo | | `$CB81,X` | track pointer hi (0 = inactive) | | `$CB83,X` | per-voice "active note" flag (0 = on) | | `$CB84,X` | per-voice timer (compared with `$A2` tempo) | | `$CB86,X` | per-voice transpose offset | | `$CB9B,X` | per-voice default note duration | | `$CBAB,X` | per-voice filter mask | | `$CBBF-C2,X` | per-voice ADSR + pulse-width state | | `$CBC3,X` | per-voice waveform / control byte mask | | `$CBC4,X` | per-voice filter routing flag | | `$A2` | global tempo (cycles per tracker step) | Each tick: - if `$CB84,X != $A2`, RTS (not time yet for this voice) - else if `$CB83,X != 0`: advance timer (`$CB84,X += $A2`), clear active flag, gate-off the SID voice (`$CBC3,X & $FE -> $D404,X`) - else: fall to `$CC8D` for **note-start** (now traced below) ### Note start (`$CC8D`) -- full sequence ``` LDA $CB81,X ; track ptr hi BEQ gate_off ; track inactive STA $FD; LDA $CB80,X; STA $FC ; zp $FC/$FD = ptr LDY = 0 LDA ($FC),Y ; read next byte BEQ gate_off ; $00 = end of track CMP #$80 BNE check_note ; $80 = end of song -> clear ptr + gate off gate_off: ; $D404,X &= $FE JMP advance_ptr check_note: AND #$7F ; mask high bit (effect flag) CMP #$61 ; note count = 97 BCC normal_note JMP $CD56 ; effect command (out of scope for now) normal_note: CLC; ADC $CB86,X ; transpose CMP #$61; BCS gate_off ; clamp -- past end of freq table TAY LDA $CEF1,Y -> $D400,X ; voice freq LO LDA $CE90,Y -> $D401,X ; voice freq HI LDA $CBBF,X -> $D405,X ; attack/decay LDA $CBC0,X -> $D406,X ; sustain/release LDA $CBC1,X -> $D403,X ; pulse-width lo LDA $CBC2,X -> $D402,X ; pulse-width hi LDA $CBC4,X ; filter flag PHP LDA $D417; EOR #$FF; ORA $CBAB,X; EOR #$FF PLP; BEQ skip_filter ORA $CBAB,X skip_filter: STA $D417 ; filter routing LDA $CBC3,X; ORA #$01 -> $D404,X ; waveform + gate-on advance_ptr: LDY = 0 LDA ($FC),Y BMI explicit_duration LDA $CB9B,X; SEC; ADC $A2; STA $CB84,X ; default duration A=0; STA $CB83,X ; active flag = 0 BEQ continue ``` Frequency tables: `$CEF1-CF52` (97 entries hi), `$CE90-CEF0` (97 entries lo). Note codes are 1..97 indexing both tables. So the music is a simple **list-of-(note, optional-duration)** stream per voice, with per-voice transpose and ADSR/filter state stored adjacently. No subroutines, no jump tables -- just a flat stream terminated by $00. ### Two reset variants | Routine | When called | Effect | | ------- | -------------------------------------- | ------ | | `$CC28` | IRQ when `$CBFF` bit 7 SET, bit 6 clear | full SID reset: write $08 (test bit) then 0 to each voice ctrl, prime per-voice timer to `$A2+1`, clear active flag | | `$CC58` | IRQ when `$CBFF` bit 7 CLEAR, bit 6 clear | soft release: `STA $D404,X` with bit 0 cleared on each voice (release current note's gate, leave timer/state alone) | ### What the IRQ does NOT do - Sprite X/Y register writes (those land in `$4293`, called from main loop -- see "Sprite system" below) - Screen RAM animation (elevators, blinking lights, etc.) - Game logic (taxi physics, pad detection, scoring) - Color RAM cycling for the title (`$4861` is called from the main loop, not the IRQ) All of those run between IRQ ticks at main-loop speed. The IRQ is strictly the **music heartbeat**. ## Sound (SID) **Gameplay is silent except for SFX.** Music tracks only load via `JSR $CB02` (musicInit), and that's called from exactly 5 sites, none of them in gameplay code paths: | Site | Song | Role | | ------- | ---- | ----------------------------------------------- | | `$459C` | 8 | title sprite setup (start title music) | | `$46BC` | 8 | title demo loop restart | | `$477C` | 25 | title init via `titleEnterContinue` ($4741) | | `$4C29` | 7 | score-screen draw (between-level jingle) | | `$4EB6` | 6 | second score-screen variant | The 8 gameplay-loop preludes at `$5F18` (`$61FB`, `$4248`, `$6946`, `$6F18`, `$6888`, `$6906`, and the two state-vec trampolines) do not load any songs. The IRQ music engine (`$CF52` → `$CC6C` per voice) still ticks every frame during gameplay, but all three voice track pointers (`$CB80/$CB81,X`) are zero so the engine walks-and-does-nothing. Audio during gameplay is purely event- driven SFX (thrust freq sweep, crash noise, etc). There is no per-level music. The port's `StLevelT.musicId` field is dormant scaffolding; loading songs at level transitions is non-authentic and was removed. Mapping of voice usage confirmed by site-by-site survey: | Voice | Register | Role | | ------------- | ------------- | --------------------------- | | 1 (triangle) | `$D400-$D406` | jingle music (non-gameplay) + thrust freq sweep + crash scream | | 2 | `$D407-$D40D` | jingle melodic support voice (non-gameplay) | | 3 (noise) | `$D40E-$D412` | crash burst + jet-engine continuous noise | Patterns observed: - **Thrust hum**: phase-1 handler decrements `$721B`, writes the shifted value to `$D400/$D401`. Continuous while `$721B > 0`. - **Jet noise**: `$6D6A` writes `$81` (noise + gate ON) to `$D412` while direction input is held; `$80` (gate off) when released. - **Crash noise**: `LDA #$81 STA $D412` (noise waveform + gate on) followed by `LDA #$80 STA $D412` (gate off) after a short duration -- this is the noise-burst envelope. - **Note release**: per-voice envelope tick at `$4320` clears the gate bit of `$D404` / `$D40B` when timers `$7216` / `$7217` hit zero. The timer is loaded from `$7218` / `$7219` (the SFX program byte that holds the wave|gate mask). - **SFX load**: `$42E9` reads a **9-byte program** from `($68/$69)` (VERIFIED via emulator): ``` byte 0: SID freq lo -> $D400+vi*7 byte 1: SID freq hi -> $D401+vi*7 byte 2: SID pulse lo -> $D402+vi*7 byte 3: SID pulse hi -> $D403+vi*7 byte 4: SID ctrl byte -> $D404+vi*7 AND stored to $7218+vi byte 5: SID AD -> $D405+vi*7 byte 6: SID SR -> $D406+vi*7 byte 7: release-timer -> $7216+vi byte 8: voice index (0,1,2 = SID voice 1,2,3) -- read FIRST ``` Routine reads byte 8 first to determine voice, gates off the selected voice ($D404+X = 0), then writes the 7 SID register values. Earlier MECHANICS draft said "voice index then 7 bytes" (wrong order, wrong byte count) -- corrected after trace. ## Sprite system (table-driven, IRQ flushed) Game code never writes the VIC sprite registers directly. It only updates **shadow tables** in main memory; a per-frame copy routine at `$4293` (called via `$5F97`) flushes them to hardware: | Shadow | Hardware | Width | Purpose | | -------------- | ---------------- | ------- | ---------------------- | | `$71A7-$71B6` | `$D000-$D00F` | 16 byt. | Sprite X/Y positions | | `$7197-$719E` | `$07F8-$07FF` | 8 byt. | Sprite-data pointers | | `$719F-$71A6` | `$D027-$D02E` | 8 byt. | Sprite colors | | `$718D` | `$D010` | 1 byt. | Sprite X MSB mask | | `$7196` | `$D015` | 1 byt. | Sprite enable mask | ### Position storage and marshaling Per-sprite positions are kept in **two parallel 8-byte tables**: - `$7175-$717C`: per-sprite pixel column (high byte of position) - `$717D-$7184`: per-sprite pixel row (Y, simple byte) `$4253` (called every frame from `$5F91`) is the **marshal**: 1. Packs the boolean array `$718E-$7195` (which holds per-sprite "feet hidden" flags) into byte at `$7196` via 8x ROL through carry. Same for `$7185-$718C` packed into the X-MSB shadow. 2. Bulk copies the two pos tables into the interleaved hardware shadow `$71A7-$71B6` (X,Y,X,Y,... order matches the hardware register layout). The flush at `$4293` is then a single tight loop: `LDA shadow,X; STA reg,X; DEX; BPL`. So the IRQ does no sprite work directly -- the per-frame routines `$4253` then `$4293` are called from the main loop, bracketed by `$404B` VBL waits to avoid tearing. ## Taxi animation (confirmed) The taxi has **two base cels** with a per-frame flicker variant, giving 4 distinct cel ptr values: - Base $C0 / $C1: cab (state A) - Base $DC / $DD: cab (state B, "flame-on") The cel-select logic in `$619B`: ``` ptr_base = (input has bit-2) ? $DC : ($C0 if bit-3 else ) parity = $7197 & 0x01 ; flickers each frame in $6A95 ptr = ptr_base | parity ``` Each phase-handler tick (`$6A95`), the taxi pointer shadow `$7197` gets `EOR #$01`, alternating the parity bit. JoeyLib port has `ST_TAXI_CEL_COUNT = 2`; should probably be 4 to fully replicate. The 9 writers of `$7197` (sprite-0 ptr shadow) reveal state- dependent base cels: | Writer site | Value | Likely state | | ----------- | ------------ | ------------------------- | | `$4A56`, `$5B82`, `$6894` | `#$C0` -> `$3000` | Menu / title cab | | `$5CA1` | `#$E2` -> `$3880` | Different state cel | | `$6A2D` | `#$CC` -> `$3300` | Phase-2 (death anim start) | | `$6A95` | `EOR #$01` | In-flight flicker | | `$6B33` | `INC` | Phase-2 cel walk | | `$619B`, `$6401`, `$65B2` | computed (in handler) | Various dispatch | ## Engine flame -- sprite 2 (VERIFIED CORRECTION) CORRECTION: the flame is **sprite 2** (not sprite 1 as I had earlier). Verified by checking `$6D6A`: it writes to `$7177` (sprite-2 X) and `$7199` (sprite-2 ptr shadow). Sprite 1 (`$7176` X, `$7198` ptr) is the **active passenger sprite** set by `$660B` / `$6650` etc. Sprite role assignment in Space Taxi: - sprite 0 (taxi): `$7175` X, `$7197` ptr - sprite 1 (active passenger): `$7176` X, `$7198` ptr - sprite 2 (flame): `$7177` X, `$7199` ptr - sprites 3-7: additional passengers / level decoration `$6D6A` (called every frame from the gated dispatch) positions sprite 2 two pixels left of the taxi and one row above (in Y-frac terms), pointed at a direction-indexed cel from table `$6DB0,X` (X = $716A direction mask). Sprite 2 is hidden by zeroing `$7190` when no input is held. So the engine flame is: - A separate sprite (sprite 1) - Positioned (taxi_x - 2, taxi_y) when active - Cel ptr depends on direction held - Hidden when no input JoeyLib port currently overlays flame as a tile/sprite under the cab; needs to be reworked to match the C64's per-frame direction-cycling 8-entry cel table at $6DB0. ## Title screen layout The title dump's screen RAM (taken with the title displayed) shows borders at rows 0, 12, 24 (40 non-space chars each) and content in rows 1-11 (logo + credits) and rows 13-23 (instruction text / animated demo area). Our extractor preserves this layout verbatim, so the port renders the same character placement once the tile bank for `title.txt` is loaded. ### Logo color cycle (`$4861-$489B`) Routine at `$4861` advances a counter at `$48A4` mod 8, looks up a color from the 8-entry table at `$489C-$48A3`, and paints that color into 11 rows x 37 columns of color RAM -- the logo area. The 8-color cycle table (C64 codes): | Slot | Value | Color | | ---- | ----- | ----------- | | 0 | `$02` | red | | 1 | `$08` | orange | | 2 | `$07` | yellow | | 3 | `$05` | green | | 4 | `$06` | blue | | 5 | `$0E` | light blue | | 6 | `$03` | cyan | | 7 | `$04` | purple | Inner loop at `$4872-$4896` writes the color to `$D828+X`, `$D850+X`, `$D878+X`, ..., `$D9B8+X` -- 11 row offsets, each 40 bytes apart, covering screen rows 1-10 (logo area). X goes 1..37. Then a follow-up `JSR $49F8` writes A to `$719F+X` for X=7..3 -- **bulk-sets sprites 2..7 color to the same logo color** so the title sprites pulse in sync with the logo. ## Bank switching for under-Kernal data (`$6283`) ``` PHP / SEI / TAX LDA $01 ; save current memory config STA $6293 ; stash via self-mod (operand of LDA #$37 below) LDA #$38 ; bit0=0 (BASIC out), bit1=0 (Kernal out), bit2=0 (Char ROM @ D000) STA $01 ; -> all-RAM mode JSR $9656 ; data-load routine, can now read $A000-FFFF as RAM LDA #$37 ; restore (self-mod operand was the saved $01) STA $01 PLP / RTS ``` So `$6283` is the **bank-switch wrapper** for accessing per-level data stored in RAM beneath the BASIC/Kernal/I/O ROMs. NOT a decompressor (earlier MECHANICS draft was wrong). The actual data-load happens in `$9656`; everything in $8000-$FFFF is open RAM during the call. This is called from `$5B34` (with A = song index, level-end death tune setup), `$621F` (round-end reset), and `$5F` segment of init. ## Mapping to JoeyLib port | C64 var/route | JoeyLib equivalent | | -------------------- | ----------------------------------------------- | | `$DC00` joystick | `jlJoystickX/Y` + `jlJoyDown` polling | | `$7169` input mask | `applyInput()` produces `thrustDx/Dy/thrusting` | | `$7148-4B` accel | `StTaxiT.ax, .ay` (currently inlined locals) | | `$714C-4F` velocity | `StTaxiT.vx, .vy` | | `$7D8F-92` templates | hard-coded constants in `stEngine.c` | | `$7D61-63` position | `StTaxiT.x, .y` (32-bit subpixel) | | `$07A6` state sentinel | `StGameT.state` enum | | `$7164` collision phase | `StTaxiT.state` (airborne/landing/dying/etc) | | `$7163` death stage | derived from above + frame counter | | `$7150` active pad | `StTaxiT.onPad` | | `$7D75` hit trampoline | explicit pickup check in `stPassenger.c` | | `$6AED` edge bounce | `clampToField()` -- needs to bounce, not stop | | `$4253` shadow marshal | implicit in `stRender.c` per-sprite draws | | `$4293` shadow flush | jlStagePresent | | `$404B` VBL wait | jlStagePresent (sync-on-present) | | `$42E9` SFX load | `stAudioSfx*()` per-event functions | | `$4320` SFX envelope | jlAudioFrameTick / per-platform mixer | | `$6D6A` flame sprite | needs sprite-1 overlay, direction-indexed cel | ## Drop-off / death state machine (`$65C1` dispatch, all stages traced) The dispatcher at `$65C1` is a 10-way `JMP table` on `$7163`. Despite "death" in earlier notes, this is actually the **fare-success + death + game-over** state machine. Same machine handles both outcomes; the branch on `$7164` (collision phase) inside stage 1 decides. | Stage | Addr | Role | | ----- | ------ | --------------------------------------------------- | | 0 | $660B | Idle RNG ticker between transitions | | 1 | $665F | Branch: success-text OR death-finalize | | 2 | $66B7 | Sprite-1 cel cycling animation | | 3 | $66DA | (entry via indirect jump; see "Self-mod" below) | | 4 | $66DD | Cel ptr walk up to $CC | | 5 | $6739 | Pad-lighting alt color, clear death-in-progress | | 6 | $6742 | Score update: BCD add to HUD | | 7 | $67A3 | -> JMP $6CE8 (HUD finalization) | | 8 | $67A6 | Sprite-1 ptr advance to $CC | | 9 | $67C6 | Final cleanup, draw game-over screen text | ### Stage 0 (`$660B`) -- idle RNG ticker Each frame: - LDA #$64 (100); JSR `$4080` (random 0..A-1) - if result >= 3, RTS (97% chance per frame to do nothing) - DEC `$71CC` (100-tick countdown); if not zero, RTS - $7164 == 0 (gate) - $71CC := $64 (reload) - JSR `$6537` -- read pad-spawn table, prep a target pad - Save pad data into `$7186/$7176/$717E` (hover position) - `$718F = $7163 = 1` -- advance to stage 1 - Reload `$715D = $715E` So stage 0 is a **probabilistic delay**: roughly every (100*100)/3 frames (~83 seconds at 60Hz) it ticks a passenger arrival, picks a pad, and transitions to stage 1. ### Stage 1 (`$665F`) -- success-text OR death-finalize ``` DEC $715D, gate $715D = $715E (reload) DEC $7198 (sprite-1 ptr) -- animates down toward $C7 LDA $7198; CMP #$C7; BEQ continue, else RTS LDA $7164 ; collision phase BEQ success ; phase 0 -> success path death: $7163 = 8 ; jump to stage 8 $7198 = $C7 ; reset cel $7152,$7D8E = 0 ; blank dying slot $7D8E = 0 ; clear active idx DEC $715C (lives--) ; lose a life RTS success: INC $7163 (-> stage 2) Draw text at $6C45 via $41C2 (probably "FARE!" or "PASSENGER!") JSR $6C7F (scoreboard refresh) JSR $43D1 (HUD redraw) RTS ``` So the cab gets a chance to recover during stage 1; if collision phase is non-zero (we crashed), death finalizes; if zero, success text draws and stages 2-7 run the scoring animation. ### Stage 2 (`$66B7`) -- sprite-1 cel cycling (sparkle) ``` DEC $715D, gate $715D = $715E INC $716E mod 4 -> X $7198 = $66D6,X ; lookup from 4-entry table RTS ``` Table at `$66D6`: `C6 C7 D9 C7` -- cycles sprite-1 ptr through 4 cels for a sparkle/flash effect. ### Stage 3 (`$66DA`) -- self-modifying indirect Stage 3 entry is `INX; JMP ($5DCE)`. The indirect vector at `$5DCE` is loaded per-stage to select a sub-handler. This is a runtime state-machine override -- whatever the level's "success" sequence needs gets pointed at via `$5DCE`. Both writers of $5DCE and the exact sub-handlers depend on per-level state we haven't fully probed. In level 1, $5DCE points back into the success-text drawer. ### Stage 4 (`$66DD`) -- "FARE COLLECTED" art ``` DEC $715D, gate $715D = $715E INC $7198 ; walk sprite-1 ptr up CMP #$CC; BEQ continue, else RTS $718F = 0 ; clear pad hover flag INC $7163 (-> stage 5) $716D = $7150 ; save active pad JSR $6537 ; pad-table read LDA $7D8E; CMP #$0B BNE -> draw the next-fare art ... draw text at $6C1E via $41C2, A=1 ... patch HUD chars at $07D2/$6C73/$6C7B with ($7D8E + $30) JSR $6C7F (scoreboard) RTS ``` So stage 4 walks the cel pointer up to $CC, then triggers the "FARE!" banner draw + scoreboard update. ### Stage 5 (`$6739`) -- pad color alt Trivial: `$7167 = 0`; JSR `$6877` (alt color); RTS. ### Stage 6 (`$6742`) -- score update via BCD add ``` DEC $715D, gate $715D = $715E DEC $7198 ; back down to $C7 CMP #$C7; BEQ continue, else RTS INC $7163 (-> stage 7) draw text at $6C5F via $41C2 with A=1 ; "ETA" or similar JSR $6C7F (scoreboard) $68/$69 = $43B9; JSR $4354 ; BCD-add fare value to $07C2,Y $68/$69 = $07E0; JSR $4354 ; second BCD add to a different field JSR $43A5 (hudInit) LDA $715C; BEQ -> one more $4354 RTS ``` So stage 6 performs **two BCD-add operations**: one onto the in-cab display at `$07C2`, one at the global score field `$07E0`. A third add fires when lives reach zero (the "final tally"). ### Stage 7-9 -- HUD finalization + game-over Stage 7 is just `JMP $6CE8` -- a HUD-flush routine. Stage 8 walks the sprite ptr up to $CC again (a second cel-walk pass). Stage 9 zeroes `$7167` and `$7163` (clearing the state machine), runs `$6877` (pad color alt), and draws the **game-over text** at `$6C38` via `$41C2` with A=1. So the full success animation flows: 0 -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 0. The death path short-circuits from stage 1 straight to stage 8 to skip the scoring animation. ## Score storage and BCD math (`$4354`) The 4-byte BCD score is rendered in screen-RAM at the HUD positions. The `$4354` BCD-add helper: ``` $4354: Y = 6 ; $721A = 0 (carry scratch) $4357: LDA ($68),Y ; source: fare value JSR $4345 ; validate ASCII digit -> A (0..9 or special) STA $6A ; per-digit value LDA $07C2,Y ; current HUD char JSR $4345 ; validate CLC; ADC $6A; ADC $721A ; digit + source + carry PHA; $721A = 0 PLA; CMP #$0A; BMI no_carry INC $721A; SEC; SBC #$0A ; subtract 10, set carry no_carry: CLC; ADC #$6A ; offset back to char code ($6A = '0' code) CMP #$6A; BNE store ; if result == $6A ('0'), use $74 (space) LDA #$74 ; leading-zero suppression store: STA $07C2,Y DEY; BPL $438F ; next digit ... ``` So the BCD score is stored as **ASCII chars at $07C2-$07C8** (7 digits), with leading-zero suppression. The `$4345` helper validates that source and dest chars are digits (or special $66 = leading '0' tag, $6A = literal '0', $74 = space). The score lives directly in screen RAM, not in a separate variable -- the chars displayed ARE the score. The fare-value blob at `$43B9` (or wherever $68/$69 points) is a 7-byte ASCII string representing the points to award; one BCD add into the HUD adds those points. Different fares can have different values by pointing $68/$69 at different blobs. ## Per-level data variations (cross-dump diff) Comparing the four available dumps: | Field | mem0000 | level1 | title | level2 | | --------------------- | --------- | ---------- | ---------- | ---------- | | `$7D8F` Y-accel | $1E (30) | $0E (14) | $19 (25) | $11 (17) | | `$7D91` X-accel | $1E (30) | $0E (14) | $19 (25) | $11 (17) | | `$7D93` Y-gravity | $03 ( 3) | $01 ( 1) | $06 ( 6) | $01 ( 1) | | `$7D95` X-gravity | $00 ( 0) | $00 ( 0) | $00 ( 0) | $00 ( 0) | | `$7D09` special pad | $03 | $01 | $05 | $03 | | `$7D5A` (active pad?) | $03 | $01 | $05 | $03 | Observations: - **X gravity is always 0** -- no horizontal drift across all observed dumps. Reasonable; no level has wind in the C64 game. - **Y gravity varies**: level 1 has $01 (light), level 2 also $01, the "title-demo" state ($19) has $06 (heavy). The mem0000 dump ($03) is likely an intermediate state during initialization. - **Accel tracks gravity** roughly: heavier gravity needs more thrust to fight it, hence $19 / $1E accel paired with $06 / $03 gravity. Level 1's gentle 14/1 is the most playable starting point. - `$7D09` and `$7D5A` correlate strongly -- both look like pad-count + special-pad-index combinations. Per-pad data starting at `$7D0A` has 8-byte stride. Title screen's 5 active pads ($7D5A=5) means a 40-byte pad-table region used. This is the per-level fixed data that the C64 game ships with; in the JoeyLib port, the equivalent comes from `DATA/levels/level??.dat` files (24 levels, A..X). ### Per-pad byte layout (VERIFIED via reader-grep) Pad table entries at `$7D0A,X*8`. Eight bytes per pad. Updated after tracing the readers in `$645C` (padDetect) and `$6620` (newFareTransition): ``` byte 0,1: cab-landing X start (16-bit, hi/lo) byte 2,3: cab-landing X end (16-bit, hi/lo) byte 4: cab-landing row Y ($7D0E,X) byte 5: passenger spawn X fractional byte ($7D0F,X -> $7186) byte 6: passenger spawn X column ($7D10,X -> $7176) byte 7: UNUSED -- no code reads $7D11,X. The $C2/$C4 values observed in dumps are dead bytes. My earlier "ground vs elevated pad" interpretation was speculation. ``` Verified via `grep -nE "[0-7] 7D"` to find direct addressing modes hitting `$7D0B` through `$7D11`. Only bytes 1, 4, 5, 6 are read at runtime. Bytes 0, 2, 3 are accessed via the X-indexed pair with byte 1 in the 16-bit compare. Byte 7 has no readers anywhere. Level 2 has 3 pads at rows $44, $BC, $5C with style codes $C2/$C2/ $C4. Title-screen demo has 5 pads -- the "press start" art lays out a logical city with that many landing spots. For the JoeyLib port these decode to `StPadT.tileX/tileW/tileY` -- the high-byte difference (01 vs 00) maps onto the C64's 320-pixel horizontal range, so converting to JoeyLib's 40-tile-wide field just divides by tile_pixels. ## Frame budget summary Total per-frame work from main-loop trace: | Phase | Calls | | -------- | ---------------------------------------------------- | | Gated | 9 JSRs (physics + sprite-cel + pad-detect + audio) | | Post-work | 16 JSRs (anim + flush + HUD + SFX envelope) | | VBL syncs | 2x $404B between marshal and flush, second at end | | IRQ | Music tick (~50us) | The two `$404B` VBL waits are the explicit frame sync points. The order is: gameplay state → animation → marshal sprite shadow → WAIT VBL → flush shadow to hardware → continue post-work → WAIT VBL → top of loop. So screen updates happen during vblank, but game logic runs full-tilt between vblanks. This is the "no fixed framerate" pattern: the music IRQ + raster sync provide timing, but the main loop can run as many iterations as it has time for between vblanks. On a stock C64 that's ~1 frame per vblank; on accelerated hardware (or in JoeyLib's port) it could be more. ## Per-level header (`$7D00-$7D08`) -- VIC colors The bytes BEFORE the pad table are a VIC-II color block. The scene-load routine at `$62F0` (called via `$621C` at round-end) does: ``` JSR $4523 ; title-style sprite setup JSR $42BC (memcpy) ; $7530 -> $0400, 1000 bytes (screen RAM) JSR $42BC ; $7918 -> $D800, ~1000 bytes (color RAM) LDX #$06 LDA $7D00,X -> $D020,X ; per-level header -> VIC color regs DEX, BPL LDA $7D07 -> $719F ; sprite-0 color shadow LDA $7D08 -> $71A0 ; sprite-1 color shadow LDX #$09; STA $7152,X (zero), DEX, BPL ; clear sprite slot table STA $715C (zero!) ; lives counter cleared at scene-load ``` So `$7D00-$7D06` maps onto VIC registers `$D020-$D026`: - `$7D00`: border color - `$7D01`: background color (BG #0) - `$7D02-04`: BG colors 1, 2, 3 (multi-color mode) - `$7D05-06`: sprite multicolor 0, 1 - `$7D07-08`: sprite-0/1 individual colors The decompressed scene data lives at `$7530-$7929` (screen) and `$7918+` (color), staged before being copied into screen RAM. These are output buffers of the decompressor at `$9656`. ## Stage 3 indirect vector (`$5DCE` -- runtime patched) The dispatcher at `$66DA` (stage 3) does `INX; JMP ($5DCE)`. In all four available dumps, bytes at `$5DCE/$5DCF` are `00 00`, which means JMP indirect to `$0000` (= BRK -- a crash). Since gameplay clearly works, the vector must be **patched at level-init time** by the bank-switched decompressor at `$9656`. Each level's compressed data (under the Kernal ROM) likely includes a per-level "stage 3 hook" address that gets written into `$5DCE` when the scene loads. Different levels can have different "success" animations by pointing the vector at level-specific code. Without a VICE dump that captures the under-Kernal RAM (BASIC out, Kernal out before saving), we can't see the decompressor or the per-level patches. The `dumpAllLevels.py` script crashes at PC `$9735` because the dumps we have show ROM contents at $A000+ instead of the game's hidden code (opcode `$02` is invalid 6502 = KIL, occurs when we try to run code from inside the BASIC ROM). **Workaround for the port**: in JoeyLib, stage 3 isn't reproduced verbatim. Our `stEngine.c` death-anim is a simpler state machine that doesn't need this indirection. ## Music engine effect commands (`$CD56`) Reached from `$CC8D` when the track byte has its high bit set AND the masked value is >= $61. The effect byte is bit-shuffled at `$CD58-$CD61` (ASL ASL ASL with C save/restore via PHP/PLP, then ROR LSR LSR) -- this rearranges the byte to extract the effect index. The result is then compared against thresholds: | A value | Target | Effect | | ------- | ------- | --------------------------------------------------- | | < $04 | $CE45 | "track-call from arg ptr" (push current, jump) | | < $08 | $CE13 | "track-return" (pop saved ptr from $CB95/96,Y) | | < $0C | $CDE4 | decrement repeat counter at $CB85,X | | < $10 | $CDD0 | decrement filter mask at $CBAA+Y | | < $18 | $CDBF | set per-voice default duration ($CB9B,X) | | < $20 | $CDAE | set per-voice transpose ($CB86,X) | | < $30 | $CD9A | set per-voice filter mask byte ($CBAA+Y) | | == $30 | $CD71 | bulk-set 14 bytes of ADSR/pulse-width ($CBBF+X..) | | > $30 | $CD68 | "skip two bytes" silent advance | The `JSR $CD43` helper advances the track pointer by 1 byte; it's used to consume the effect byte + any arg bytes. After processing an effect, control returns via `JMP $CDA8` (continue advancing track) or `JMP $CC99` (restart note-start with new track ptr after a track-call). So the supported effects are roughly: track-call, track-return, repeat-count, set transpose, set duration, set filter routing, load instrument (14-byte ADSR/pulse-width block), and "skip". This is a competent multi-pattern tracker with subroutine support. For the JoeyLib port: approximate timbres are fine. We don't need to reproduce the full effect set; the simple "note + duration" stream covers most of the music with reasonable fidelity. ## Title-screen sprite animator (`$4523-$45A7`) Entry via `JSR $4523` from `$62F0` (scene-load). The 4-byte LDA #$C0 / STA $719E sets sprite-7 ptr; then setup at `$4525` for the title sprite cloud. Falls through to the pseudo-random demo animator at `$4666`: ``` $4666 loop top: X = $68 (current sprite index) if $44F8,X (per-sprite timer) == 0: JMP $4711 ; "exit demo" -- only when all timers zero DEC $44F8,X if non-zero, skip respawn -> $46A0 respawn at $4675: $718E,X = 1 ; mark sprite "visible" $7185,X = 0 ; clear X-frac new X = ($717D & 7) + $A6 ; columns $A6..$AD (8 wide) $7175,X = new X new Y = (($717D & $30) >> 4) + $89 ; 4 vertical bands at $89/$8A/$8B/$8C $717D,X = new Y $4500,X = $20 ; reload counter $46A0: DEC $68; BPL $4666 -- loop X = 6 down to 0 $46A4: JSR $4253 (shadow marshal) JSR $404B (VBL wait) JSR $4293 (shadow flush) $7222 = 0 JSR $4FCB (runStopWatcher) if $7222 != 0: JSR $CB02 with A=$08 (restart music) $46BF: INC $4508 (loop counter); ... ``` So the title-demo is **per-sprite countdown timers**: each of the 7 demo sprites has a counter at `$44F8,X` and a respawn counter at `$4500,X`. When the per-sprite timer hits zero, the sprite respawns at a pseudo-random position derived from the taxi row (`$717D`) LSBs -- so the "randomness" is actually driven by the demo cab's own motion. Once all sprites have zero timer, JMP $4711 transitions to gameplay. The per-direction respawn position is reproducible (deterministic given the same $717D sequence) so the original always shows the same demo, frame-perfect. For the JoeyLib port: render the title screen as a static image, or implement the same per-sprite-timer-respawn pattern with positions derived from a frame counter. Either works; the latter matches the original's quirky-but-charming flicker effect. ## Score / fare-value blobs (`$43B1` table) -- VERIFIED via emulator trace Four pre-defined 7-byte fare-value blobs at `$43B1, $43B9, $43C1, $43C9`. Each blob is a 7-char screen-RAM template: ``` $43B1: 66 66 66 74 77 74 74 ; HUD init (all blanks + separator) $43B9: 66 66 66 6F 77 74 74 ; basic fare = +5 $43C1: 66 66 73 6F 77 74 74 ; alt fare A = +95 $43C9: 66 66 6F 74 77 74 74 ; alt fare B = +50 ``` Char-code interpretation (via $4345 validation): - `$66`: leading blank (treated as 0 in BCD add) - `$74`: trailing space (treated as 0) - `$6A-$73`: digits 0..9 ($6A = '0', $73 = '9') - `$77`: separator (position 4 -- BCD-add SKIPS this position so the separator char is preserved across adds) - Other chars: undefined behavior Verified by running `$4354` through the 6502 emulator (see `stuff/spacetaxi/trace.py`) with each blob against an all-blank HUD. The actual byte-level result for each: | Blob | HUD after BCD-add (chars) | Numeric effect | | ------ | ------------------------- | --------------------------- | | $43B1 | `___ _ ` | identity (no digits) | | $43B9 | `___5_ ` | +5 in pos 3 (ones) | | $43C1 | `__95_ ` | +95 in pos 2 (tens) + pos 3 | | $43C9 | `__5 _ ` | +50 in pos 2 (tens) | If the C64 HUD layout is `DDDD.DD` (4 integer + separator + 2 decimal), then position 3 is the ones-digit of integer. So: - `$43B9` = $5 basic fare - `$43C9` = $50 bonus - `$43C1` = $95 jackpot Callers (verified in disassembly): | Site | Blob | Game event | | ------- | ------ | ------------------------------------------------ | | `$6780` `$43B9` | Stage 6 ($6742) basic fare delivered | | `$5C82` `$43C9` | After death-fall reaches floor row $D4 (untraced precise trigger) | | `$5D03` `$43C1` | After per-passenger counter decrement hits zero (untraced precise trigger) | The `$5C82` and `$5D03` precise game-state triggers are not yet fully traced -- they fire during animation/state-transition flows whose state pre-conditions aren't fully mapped. For the JoeyLib port: `ST_FARE_SCORE = 5` per delivered fare (matches verified `$43B9` BCD effect). The +50 and +95 bonuses aren't wired up since their trigger conditions are unverified. **Previously I claimed "flat 500 per fare" without ever running the BCD math; that was wrong.** ## Game-end condition VERIFIED: $71CE == $7213 (fare target) `$71CE` ("fares done") increments only at `$6BD6` inside `$6BD0`, which is reached only when `$5CF1 >= 3` (the rare end-of-sequence state). When `$71CE == $7213`, `$6BE1: JMP $602F` fires, which `JMP $5EC4` -- the **gameInitContinue** entry, effectively restarting the game (zeroes `$71CE`, `$7215`, runs title path). `$7213` (fare target) is **player-selectable on the title screen**: - `$48AF` sets it to `$01` default - `$5310-$5328` is a title-screen UI loop: - RIGHT held (`$7169 & $08`): INC `$7213`, mask to 0..3 (= 0..3 + 1 baseline = 1..4) - LEFT held (`$7169 & $04`): DEC `$7213` - Other input: continue So Space Taxi's "difficulty" = how many fares per game. Default 1, selectable 1..4 at title via joystick. After that many deliveries, the game ends and returns to title. The JoeyLib port's per-level fare progression (24 sequential levels with their own fare counts) is a port-side invention -- the C64 game restarts at title after the player-chosen fare count, with no inter-level progression. ## `$5C2C` drop-off animation (NOT death-fall -- VERIFIED) Earlier I mis-labeled `$5C2C` as "deathFallAnim". Re-tracing shows: when `$5CF1 = 0` (the default state), `$5BBB` jumps to `$5C2C` which runs physics each tick until the cab's row reaches `$D4` (row 212 = near screen bottom). Then it executes the "finalize" block: ``` $5C77: $68=$E0; $69=$07 ; source pointer = $07E0 (HUD blank) $5C7F: JSR $4354 ; BCD-add $07E0 to $07C2 -- noop (source is blanks) $5C82: $68=$C9; $69=$43 ; source pointer = $43C9 (+50 fare blob) $5C8A: JSR $4354 ; BCD-add: $07C2 += 50 $5C8D: JSR $43A5 ; hudInit -- re-template $07E0 $5C90: RTS ; back to caller ``` Verified by emulator trace: starting cab at row `$D3`, running `$5C2C` directly. 642 instructions executed. Cab ends at `$D4`. JSR chain captured: $4354 (twice) and $43A5 (hudInit). Score at $07C2 advances by +50 (the `$43C9` blob delta verified earlier). So **`$5C2C` is the drop-off animation**, awarding +50 (the bonus fare blob) once the cab reaches the destination pad floor. NOT a death sequence -- the cab moves to its target, gets +50 score, the fare is delivered. The `$5CF1` state-selector ranges in `$5BBB`: | $5CF1 | Path | Role | | ----- | -------------- | ------------------------------------- | | 0 | `$5C2C` | drop-off animation (+50 bonus on finalize) | | 1 | `$5C91` | takeoff animation | | 2 | `$5CB9` | pad-to-pad transition | | 3+ | fall through to `$5BD1` | restore physics templates, re-enable trampolines, then `JMP $6BD0` | So `$6BD0` is reached ONLY when `$5CF1 >= 3` -- a rare state that's probably the actual game-over path. The "GAME OVER!" text drawn by `$5FBB` therefore does NOT display on every drop-off as I claimed earlier; only on the specific `$5CF1 >= 3` transition. ## Level-end / game-over flow After phase-3 finishes (`$6B4C`), control falls to `$6BD0` (reached via the `$5BD1` template-restore path, not every death): ``` $6BD0: JSR $704E ; archive HUD row + score row to backup tables $6BD3: JSR $5FBB ; (if game-over) draw "GAME OVER" + delay $6BD6: INC $71CE ; bump fares-completed counter $6BD9: CMP $7213 ; compare to fares-needed $6BDF: BNE $6BE4 -> JMP $5F02 ; not done, main loop $6BE1: JMP $602F ; all fares done, level complete $6BE4: JMP $5F02 ``` `$704E` archives the current HUD: copies the 7 chars at `$07C2` (in-cab text) and 7 chars at `$07E0` (score row) into per-passenger backup tables at `$71D3,X*8` and `$71F3,X*8` (X = current fare idx $7214). This lets a respawn restore the per-fare HUD state. `$5FBB` (drawn only via `$6BD3` on game-over path) renders the **"GAME OVER" screen**: - Three lines at column 14, rows 10/11/12 drawn via `$41C2` from source pointers `$6C38`, `$6C52`, `$6C38` - `$D01B = $FF` (push all sprites BEHIND the background) - Kill voice 1+2 control + voice 3 gate-off - Four sequential busywait loops at `$4101` with X=$FF each (~80ms total visual pause) `$5010` is the **level-end dispatcher** called from `$61FB` round-end: | State | Action | | ---------------------------------- | -------------------- | | `$5E9D` non-zero (post-mortem) | JMP $505A (full game-over screen) | | `$721C` non-zero (menu mode) | JMP $50C6 | | `$7215 == $18` | JMP $5167 (level-complete pause) | | `$7215 == $19` | JMP $602F (next-level intro) | | `$7221 == 1/2/3` | JMP $517D (sub-paths) | | `$7221 == 4` | JMP $519C | | `$7221 == 5` | JMP $51A6 | | else | JMP $5EC4 (gameInitContinue) | So `$7215` is the level-progression counter ($18 = "level done, show score"; $19 = "advance to next level") and `$7221` is a sub-state selector for finer-grained transitions. `$5BBB` is the **respawn-state dispatcher** that branches on `$5CF1`: | `$5CF1` | Path | | ------- | ------------------------------------------------------ | | 0 | JMP $5C2C (death fall anim: $6032 physics + clamp $D4) | | 1 | JMP $5C91 (takeoff anim: $6032 physics + clamp <$87) | | 2 | JMP $5CB9 (pad-to-pad fly-by sequence) | | else | Falls through to $5BD1 (level template restore) | So `$5CF1` (earlier labeled gCacheUnknown) is the **transition-type selector**: 0=falling, 1=taking off, 2=moving between pads, else=normal level init. ## Music index cycling (`$6279`) `$6279` decrements `$716B` (music song index) by `$41`. Called from `$621F` round-end-reset. Each fare completion rotates the song: each level isn't tied to one song; the music shifts as the player progresses through fares within a level. ## Game-init self-popping trampoline (`$4741`) ``` $4741: PLA / STA $473C ; save caller's return-addr LO $4745: PLA / STA $473D ; save HI $4749: TSX / STX $473E ; save stack pointer $474D: JSR $477A ; load song 25 via $6283 + JSR $6F95 $4750: A=$80 -> $D412 (voice 3 off) D015=0, D020=D021=0, $C6=0 JSR $40CA (fillScreenAndColor) if $721C != 0: JSR $5295 (score-screen draw) $476D: TSX/TXS restore ; restore stack pointer Push saved return addr back $4779: RTS ; return to caller as if nothing happened ``` This is a clever **JSR-but-don't-consume-stack** pattern. The routine pops its own return address, does setup with possibly modified call chains, then pushes the original return back. The net effect is that the caller never knows $4741 ran -- but $4741 did get to insert title-screen song setup + buffer fill. Called from `$5EF7` in gameInit. So gameInit goes through: ``` $5EC1 gameInit -> $CC00 install IRQ -> ... clear buffers -> $5EEC JSR $528A (zero $5254 buffer) -> if $5E9D set: JMP $48AD (title) -> $5EF7 JSR $4741 (transparent song-25 + clear setup) -> if $721C set: JMP $48AD (title) -> $5F02 main entry... ``` ## Under-ROM data (from raw.bin) The `raw.bin` capture was taken with `bank ram` set in VICE so the RAM hidden beneath the BASIC/Kernal/I/O ROMs is preserved. With it we can see: - The decompressor at `$9656` and its helper bit-stream reader at `$96A3/$964A` - The scene pointer table at `$9600` (28 entries, 2 bytes each) - The compressed scene data at $D000-$FCFF and $8100-$9418 - The music engine code at $CB00-$CFFF and frequency tables at $CE90 (lo bytes) / $CEF1 (hi bytes) ### Scene pointer table (`$9600`) 24 game levels (A..X = scenes 0..23) + special scenes: | Scene | Letter | Data addr | | ----- | ------ | --------- | | 0 | A | $D000 | | 1 | B | $D1E4 | | 2 | C | $D3E0 | | 3 | D | $D700 | | 4 | E | $D8F5 | | 5 | F | $DB15 | | 6 | G | $DD77 | | 7 | H | $E075 | | 8 | I | $E416 | | 9 | J | $E6F6 | | 10 | K | $E9BF | | 11 | L | $EBCE | | 12 | M | $EE79 | | 13 | N | $F05F | | 14 | O | $F251 | | 15 | P | $F46D | | 16 | Q | $F767 | | 17 | R | $FA61 | | 18 | S | $FC9F | | 19 | T | $8100 | | 20 | U | $8340 | | 21 | V | $86A7 | | 22 | W | $8943 | | 23 | X | $8C7B | | 24 | title | $8F7D | | 25 | ? | $9418 | | 26+ | invalid | (data garbage) | ### Decompressor (`$9656`) ``` $9656: TXA / ASL A / TAX ; X = scene*2 (word index) $9659: LDA $9600,X ; load LO of pointer $965C: LDY $9601,X ; load HI $965F: STA $964B / STY $964C ; self-mod the LDA at $964A $9665: LDY=0; LDX=3 $9669: JSR $964A ; read 1 bit from stream STA $93,X; DEX; BNE ; build 4-bit token $9672: TYA; AND #$0F BEQ $968B ; skip if zero $9677: ... shift/copy table-build ... $968B: STA $039C,Y ; write to output table STA $0368,Y ; output a second table too JSR $96A3 (read 4 bits) -> $0334,Y $969A: INY; CPY #$34; BNE $9671 ... walk output ... ``` It's a per-scene **bitstream decoder** that builds three tables at $0334, $0368, $039C from a compressed stream. Each scene blob is roughly 250-700 bytes that expands to 1000 cells of screen RAM, 1000 cells of color RAM, plus the per-level data table at $7D00. The decompressor uses self-modification for the read pointer ($964B/$964C are operand bytes of the LDA at $964A), enabling the same code to walk any scene's pointer. ### Per-level data table for all 24 levels (VERIFIED via emulator) Extracted by running the decompressor for each scene through `cpu6502.py` and reading `$7D00-$7D9F` after. X-accel and Y-accel are stored separately (`$7D91/92` X, `$7D8F/90` Y) and DIFFER on several levels. X-gravity (`$7D95`) is non-zero on levels P and U (side-wind). Y-gravity (`$7D93`) is signed -- level K = `$F9` = -7. | Lvl | Pads | SpawnX | SpawnY | X-acc | Y-acc | X-grav | Y-grav | Bord/BG | | --- | ---- | ------ | ------ | ----- | ----- | ------ | ------ | -------- | | A | 1 | $0A00 | $4A00 | $0E | $0E | $00 | $01 | $00/$00 | | B | 3 | $8800 | $8800 | $11 | $11 | $00 | $01 | $00/$00 | | C | 5 | $AA00 | $4400 | $12 | $10 | $00 | $02 | $00/$00 | | D | 10 | $8800 | $8800 | $11 | $14 | $00 | $06 | $00/$00 | | E | 9 | $FA00 | $C800 | $11 | $15 | $00 | $06 | $09/$00 | | F | 3 | $AA00 | $6400 | $15 | $13 | $00 | $03 | $00/$00 | | G | 5 | $AF00 | $5600 | $14 | $18 | $00 | $06 | $0C/$00 | | H | 5 | $9600 | $3E00 | $19 | $19 | $00 | $06 | $00/$00 | | I | 7 | $AA00 | $6E00 | $15 | $13 | $00 | $03 | $00/$00 | | J | 6 | $1E00 | $3E00 | $19 | $19 | $00 | $05 | $09/$00 | | K | 5 | $B400 | $5F00 | $17 | $15 | $00 | $F9 | $00/$00 | <- anti-gravity | L | 6 | $6E00 | $4600 | $1A | $1A | $00 | $03 | $0B/$0B | | M | 10 | $AF00 | $3E00 | $40 | $46 | $00 | $06 | $06/$00 | <- highest accel | N | 8 | $AF00 | $3E00 | $12 | $16 | $00 | $06 | $00/$00 | | O | 1 | $2E00 | $3400 | $15 | $13 | $00 | $03 | $00/$00 | | P | 3 | $2400 | $4A00 | $15 | $15 | **$04**| $06 | $0B/$00 | <- side wind | Q | 8 | $B400 | $4600 | $19 | $19 | $00 | $05 | $0B/$00 | | R | 1 | $E600 | $3800 | $10 | $10 | $00 | $01 | $00/$00 | | S | 5 | $AA00 | $4400 | $12 | $10 | $00 | $03 | $07/$08 | | T | 8 | $AF00 | $C800 | $1B | $26 | $00 | $06 | $06/$00 | | U | 5 | $AA00 | $4A00 | $18 | $18 | **$02**| $02 | $00/$00 | <- side wind | V | 7 | $AF00 | $3E00 | $1E | $20 | $00 | $06 | $06/$00 | | W | 3 | $3200 | $4600 | $1E | $1E | $00 | $03 | $00/$00 | | X | 8 | $AF00 | $4600 | $17 | $17 | $00 | $06 | $0B/$00 | Sprite colors (`$7D07`/`$7D08`) verified `$06` (blue) for EVERY level -- so the cab color is constant across the game; only the border/BG palette changes. Key gameplay-affecting observations: - **Levels P and U have side-wind**: non-zero X-gravity (`$04` and `$02` respectively). Cab drifts horizontally without input. - **Level K has anti-gravity** (`Y-grav = $F9` = -7 signed). The cab is pulled UP each frame. - **X-accel != Y-accel on most levels**. The cab is more responsive on one axis. E.g., level T: xAcc=$1B but yAcc=$26 (38% more vertical thrust). Level M is the extreme: x=$40, y=$46. - **Pad count** grows from 1 (A, O, R) to 10 (D, M). - **Border/BG colors** vary; most levels are black (0/0). Level S has yellow/orange (`$07/$08`), level L is dark gray on dark gray. The .dat files in `examples/spacetaxi/generated/levels/` carry the same xAccel/yAccel/xGrav/yGrav values verbatim (verified by parsing the binary headers). **Pad style bytes (byte 7 of each 8-byte pad slot):** Level | Style bytes --- | --- A | $C2 B | $C2 $C2 $C4 C | $C2 $C2 $C4 $C2 $C4 D | $FF $FF $FF $FF $FF $FF $FF $FF $FF $FF (uninitialized?) E | $C2 $C2 $C4 $C2 $C4 $C2 $C4 $C2 F | $C2 $C4 $C4 G | $C2 $C2 $C2 $C2 $C4 H | $C4 $C4 $C2 $C2 $C2 I | $C2 $C2 $C2 $C2 $C4 $C4 $C4 J | $C2 $C2 $C4 $C2 $C4 $C4 K | $C2 $C2 $C2 $C4 $C4 L | $C2 $C4 $C2 $C2 $C4 $C4 M | $C2 $C2 $C4 $C4 $C4 $C4 $C4 $C2 N | $C4 $C2 $C2 $C4 $C4 $C4 $C2 $C2 O | $C2 P | $C2 $C4 $C2 Q | $C4 $C2 $C2 $C4 $C2 $C4 $C2 $C4 R | $C4 S | $C2 $C2 $C4 $C2 $C4 T | $C2 $C4 $C2 $C2 $C4 $C4 $C2 $C4 U | $C4 $C2 $C2 $C4 $C2 V | $C2 $C2 $C2 $C2 $C2 $C4 $C4 W | $C4 $C2 $C2 X | $C4 $C4 $C4 $C4 $C2 $C2 $C2 $C2 Pad-style codes only vary between `$C2` (mostly) and `$C4`. Level D's all-`$FF` is suspicious -- either uninitialized scene data or a sentinel meaning "use default style". ## Stage 3 is dead code (the `$5DCE` mystery resolved) Empirically confirmed via grep across BOTH the visible RAM listing AND the under-ROM listing: **no code writes to `$5DCE` or `$5DCF`**. The bytes stay at $00 $00 throughout execution, so `JMP ($5DCE)` at stage 3's `$66DA` would jump to address $0000 = BRK = crash. This is fine because **stage 3 of the `$65C1` dispatcher is never reached during normal play**: stage 2 of $65C1 (`$66B7`) is just a cel-cycle loop with no `INC $7163`, so the state machine sticks at stage 2 and never tries to advance to 3. Stages 1 through 9 ARE reached, but via a **parallel state machine at `$4A03`** (driven from the score-screen / level-intro path at `$47C9`+ that sets `$7163 = 2` directly). $4A03's dispatch handles stages 2/3/4/5 by calling specific subroutines (e.g. stage 2 calls `$66B7` as a SUBROUTINE rather than via the $65C1 dispatch). Stage 3 of $4A03 (`$4A24`) is a hover-style routine that doesn't use $5DCE at all. The `INX / JMP ($5DCE)` at `$66DA` is **legacy code from an earlier version** where the state machines were unified. Now it's just an unreachable trap. ### Parallel state machine (`$4A03`) Dispatcher with its own jump table on `$7163`: | `$7163` | Target | Role | | ------- | ------- | ----------------------------------------------- | | 2 | $4A17 | sparkle-cel cycle ($66B7) + 90-frame wait | | 3 | $4A24 | hover positioning (uses $6D0D pad-lookup) | | 4 | $4A45 | JMP $67A6 (sprite ptr walk to $CC) | | 5 | $4A48 | final hover + noise gate-off | This runs as a **sequence** during the score-screen / "press fire to start" transition, called from `$47E4: JSR $4A03`. Caller sets `$7163 = 2` and `$473F = $5A` (90-frame counter), then runs the machine via JSRs (not main-loop dispatch). So Space Taxi has **TWO state machines on $7163**: - `$65C1` runs every frame from post-tick (stages 0-9, but only 0/1/2/4-9 are reachable; the 2->3 path is broken) - `$4A03` runs from the intro/transition path (stages 2-5, fully reachable) ## Music frequency tables (under-ROM) - `$CE90-$CEF0`: 97 entries, **freq LO byte per note** - `$CEF1-$CF51`: 97 entries, **freq HI byte per note** The note indices 0..96 cover roughly 8 octaves. Note 0 = $0000 (silence), notes 1..96 give the SID 16-bit frequency code for each chromatic step. Looking up: at index 13 (octave 1, note D) the freq is $0238 = 568, which translates via the SID formula to ~33 Hz -- low D. The table goes up to ~3.5 kHz at the top. These tables are normal 12-tone equal temperament with the C64 SID's clock-divider scaling. For the JoeyLib port: any standard PSG / FM / sample-based pitch table will sound musically correct. ## Open work (truly remaining) 1. **Bonus / tip score blobs**: blobs at `$43C1` and `$43C9` are not yet traced to their callers. The flat-rate 500-point blob ($43B9) is the primary; the other two are likely triggered by specific event scenarios we haven't observed. 2. **Music engine effect arg-byte semantics**: the major effect types are mapped, but the exact byte-layout of each effect's args (how many bytes after the opcode, what they mean) is still case-by-case. Approximate playback works without this. 3. **Menu-state script interpreter** at `$48F2-$494F`: consumes a byte stream from the `$0900` buffer. The opcode table for the script bytes (timing, text display, animation triggers) needs to be traced opcode-by-opcode by stepping through the title demo in VICE. 4. **Level D's `$FF` pad-style placeholder**: byte-7 of all 10 pad slots is `$FF` in level D. Either a level data bug in the original game or the game substitutes a default style when it sees $FF. Worth confirming by playing level D in VICE.