joeylib2/examples/spacetaxi/MECHANICS.md

88 KiB

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
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 <RTS>.
  • 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 <keep>)
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
L 6 $6E00 $4600 $1A $1A $00 $03 $0B/$0B
M 10 $AF00 $3E00 $40 $46 $00 $06 $06/$00
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
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
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.