# 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 <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, then load a
disk file selected by `$6F75,X` (X = `$716B`) via the `$9936`
fast-loader, set $5E9D=0, JMP $5EC4. **Exit-to-title cleanup**.

CORRECTED 2026-07-20 (demo-subsystem trace): the `$6F75,X` table and
the `$9936` loader do NOT load music. `$6F75` holds a FILENAME DIGIT
patched into the reversed "S0:SPACETAXI 0_/N0S" string, and the four
"SPACETAXI 01..04" disk files are the **attract-demo recorded input
streams** (RLE `[joystick mask][tick count]` pairs; H/T/W/X screen
rotation, `$6F75` maps H=01, T=02, W=03, X=04; playback substitutes
the recorded mask for `$DC00` input via the `$48F2` hook). `$716B`
is the demo rotation index, not a music index. The music engine
proper ($CB00-$CFFF, per-voice streams, freq tables) is unrelated to
these files.

## 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 (traced -- see "Effect commands" below)

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 HI ($D403 is the PW hi register)
LDA $CBC2,X -> $D402,X  ; pulse-width LO
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 (CORRECTED 2026-07-20 against the trace above:
the earlier version of this line had lo/hi swapped): `$CEF1-CF51`
holds the 97 freq LO bytes (written to `$D400,X`), `$CE90-CEF0`
holds the 97 freq HI bytes (written to `$D401,X`). Note codes are
1..97 indexing both tables. Verified against raw.bin bytes:
`$CE90: 00 01 01 01 ...` (small = hi bytes of low notes),
`$CEF1: 00 0C 1C 2D 3F 51 ...` (lo bytes).

CORRECTED 2026-07-20 (full byte-level trace of `$CD0E-$CD55`): the
stream is **one byte per note event**, NOT (note, duration) pairs.
After the note plays, `$CD0E` re-reads the SAME byte at `($FC)` --
the pointer has not advanced yet -- and `BMI $CD24` tests the NOTE
byte's own bit 7 (the bit masked off by `AND #$7F` before the table
lookup):

- bit 7 CLEAR: default duration. `$CB84,X = $CB9B,X + $A2 + 1`
  (next event = now + default duration + 1 jiffy), `$CB83,X = 0`.
- bit 7 SET: staccato/gap mode using `$CBC5,X`: if `$CBC5,X` is
  negative it is first added to `$CB9B,X` (clamped at 0). The result
  is stored BOTH into `$CB84,X` (+`$A2`+1, the note-on time) AND via
  self-modifying code into the `SBC` immediate at `$CD3F`, and
  `$CB83,X = $CB9B,X - 2 - (that value)`... i.e. `$CB83,X` receives
  the GAP length. `$CB83,X` is therefore a gap counter, not a plain
  flag: when the tick at `$CC74` finds it nonzero, it gates the voice
  OFF and schedules the next (note-start) event at `now + $CB83,X`
  (`CLC ADC $A2`). "0 = on" in the state table above really means
  "no pending gap".

Then `$CD43` advances the track pointer by ONE byte (the note byte)
and refreshes zp `$FC/$FD`. Flat stream, terminated by `$00` (end of
track: gate off, pointer stays) or `$80` (end of song: pointer set to
`$FFFF`, gate off).

### Effect commands (`$CD56`) -- traced 2026-07-20, previously "out of scope"

A stream byte whose masked value `(byte & $7F) >= $61` is an effect.
`$CD56` re-reads the raw byte and transforms it with an
ASL/PHP/ASL/ASL/PLP/ROR/LSR/LSR dance that computes
`cmd = (bit7 << 5) | (byte & $1F)` -- so `$61-$7F` map to cmd
`$01-$1F` and `$E1-$FF` map to cmd `$21-$3F`. Dispatch (compare
chain, byte-exact; interpretive names are inference from the code
shapes, marked *):

| cmd range | bytes | action |
| --------- | ----- | ------ |
| `> $30`   | 3     | skip two operand bytes (unused command class in this build) |
| `= $30`   | 1+14  | *instrument load: copy the next 14 stream bytes into `$CBBF+`(voice window) -- ADSR, pulse width, waveform mask, filter state (`$CD71`, self-modified X operands) |
| `$20-$2F` | 1     | *set repeat counter: `$CBAA+Y = cmd & $0F` where `Y = $CB85,X + $CBD6` (`$CE84` slot indexer) |
| `$18`     | 2     | set per-voice transpose `$CB86,X` from the next byte (`$CDB2`) |
| `$10-$17` (not `$18`) | 2 | set per-voice default duration `$CB9B,X` from the next byte (`$CDC3`) |
| `$0C-$0F` | 1     | decrement repeat counter (`SBC 1, AND $0F`) (`$CDD4`) |
| `$08-$0B` | 1     | *loop-back: condition via `$CE68` (cmd&3: 2 = if counter zero, 3 = if counter nonzero, else always); taken -> pop `$CB95/96,Y` saved pointer (`DEC $CB85,X` twice) and continue there; a negative slot index instead resets `$CB85,X` and ends the song (`$CDE8`) |
| `$04-$07` | 1 or 3 | *loop-start: condition false -> skip 2 operand bytes; true -> push `ptr+3` onto the `$CB95/96` pointer stack (`INC $CB85,X` twice) then fall into the jump below (`$CE17`) |
| `$01-$03` | 3     | *relative jump: add the 16-bit LE operand (bytes at ptr+1/+2) to the track pointer (`$CE4A`) |

After most commands control returns to `$CC99` to process the next
stream byte in the same tick (so command chains execute
"immediately", consuming no time).

### Song loading -- `$4955` is a patch stub, the real loader is `$9936`

`$4955` only silences the SID (`$D404 = 0`, `$D40B = 0`,
`$D412 = $80`), then: `LDX $716B` (music index), `LDA $6F75,X`,
`STA $99A7`, `JSR $9936`. **`$6F75` is not a pointer table -- it
holds a FILENAME CHARACTER** (observed bytes: index 7 -> `$31` '1',
19 -> `$32` '2', 22 -> `$33` '3', 23 -> `$34` '4'), and `$99A7` is a
character slot inside a REVERSED filename string at `$99A3+`
("S0N/_0 IXATECAPS:0S" = "S0:SPACETAXI 0_/N0S" reversed; a second
string at `$9990` is "SPACETAXI HI"). `$9936` is a self-modifying
IEC fast-loader (raw `$FFA8` CIOUT / `$FFAE` UNLSN serial traffic)
that pulls the song file off the disk. **The music DATA is not in
the resident program at all: each song ships as its own disk file**
-- the `SPACETAXI 01..04` / `SPACETAXI HI` PRGs (present in
`stuff/spacetaxi/extract/`). Whatever song was resident when the
raw.bin dump was taken is what sits in its load area there.

Timing base: `$A2` in the tick compare is the KERNAL jiffy clock low
byte, so all durations are in PAL jiffies (50 Hz) -- `$CB84,X` is a
"next event at this jiffy" target, not a countdown.

### 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  |   <- 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.
