spacetaxi/VERIFIED.md
2026-10-19 15:47:41 -05:00

21 KiB

Space Taxi disassembly: verified claims

Each row below references a claim in MECHANICS.md and the trace that verifies it. Trace scripts live in stuff/trace.py plus the inline scripts run during verification sessions.

VERIFIED via emulator trace

Claim Evidence
$4354 BCD-add with blob $43B9 modifies HUD position 3 with digit '5' Run $4354 against all-blank $07C2. Result: $07C2-$07C8 = $66 $66 $66 $6F $66 $74 $74. Only one write to $07C5.
Fare blob $43C1 = +95 (positions 2,3) Same harness, different blob. Result has digits '9' at pos 2, '5' at pos 3.
Fare blob $43C9 = +50 (position 2) Same harness. Result has digit '5' at pos 2.
Stage 6 ($6742) writes only to $07C2, never $07E0 Memory-write trace shows the only HUD writes target $07C5.
$07E0 has no in-game writers except $43AA (hudInit template copy) grep across live-raw.lst finds only $43AA and $635F (sceneLoad restore).
$6032 physics: X-accel suppressed when $7197 & 1 == 1 via $6190 Run physics with RIGHT-held and $7197 = $C1: ax = 0. With $7197 = $C0: ax = +14.
$6032 physics: Y-accel NOT gated by parity Same test with UP/DOWN held + various $7197: Y accel always = +/-yAccel regardless of parity.
Parity bit $7197 & 1 toggle sites are death-phase-1 ($6A93/95) and FIRE-button rising edge ($63FF/$6401) grep EOR #$01 ... STA $7197 across all listings -- no other writers.
$619B (gated dispatch) preserves the parity bit Code at $619B-$61BC: ORA with captured parity $61 before STA $7197.
$645C pad detection requires EXACT row match At pad-table row 204 with cab at (115, 204): $7150 = 0 -> 1. At row 203 (1 off): stays 0.
$645C pad detection X-bounds work At (100, 204): stays 0 (X<110). At (208, 204): stays 0 (X>198). At (115, 204): $7150 -> 1.
$6966 collision dispatch: bg-collision triggers DEATH With $D01F & 1 = 1, phase advances 0->1, sprite-0 cel $C1->$CC.
$6966: sprite-0 + passenger collision (bits 0+3 in $D01E) ALSO triggers death (in raw.bin state) With $D01E = $09: phase->1, $721D=1 (trampoline RTS preserved A=1).
$6966: sprite-sprite without sprite-0 bit does NOT trigger death With $D01E = $08 (only bit 3): phase stays 0.
$7D75 trampoline is STATIC in raw.bin and level1.bin Direct read: bytes $4C $9A $7D in both dumps. No writers anywhere in the code.
$7D75 trampoline returns A unchanged in default state Patched the operand to a LDA #$00; RTS stub: passenger collision no longer triggers death (phase stays 0). With default RTS: phase advances.
$65C1 death-stage dispatch JMP table verified for stages 1-9 Instrumented run of $65C1 with each $7163 value; first JMP captured matches the documented target ($665F, $66B7, $66DA, $66DD, $6739, $6742, $67A3, $67A6, $67C6). Stage 0 is fall-through (no JMP).
Per-level X-accel and Y-accel can DIFFER Levels C, D, E, F, G, K, M, N, T, V have $7D91 != $7D8F after decompress.
Per-level X-gravity is non-zero on levels P and U Levels P ($7D95 = $04) and U ($7D95 = $02); all others = 0.
Level K is anti-gravity ($7D93 = $F9) After 10 ticks of physics with no input, cab Y position DECREASES (moves up), vy = -70.
Level P side-wind drifts cab right at $4/frame After 20 ticks: vx = +80, x position drifted +840 sub-units.
.dat files preserve all per-level fields Parse STL2 header for all 24 levels; xAccel/yAccel/xGrav/yGrav/border/bg match emulator extraction byte-for-byte.
$CC6C music tick is a NO-OP when track pointers are zero Run with $CB80/$CB81 = $00, X = 0/7/14: 4 instructions executed, 0 SID writes. Confirms "gameplay is silent" because no tracks are loaded.
In post-decompress state for level A, ALL THREE voice track pointers are zero Direct read of $CB80/81, $CB87/88, $CB8E/8F after run_scene(0): all $00 $00. So even if the IRQ runs (which my emulator can't simulate due to the KERNAL $EA31 exit being absent), there's nothing for it to play. Gameplay is silent.
$5FBB draws "GAME OVER!" to row 11 of screen RAM Direct trace of $5FBB writes the chars $47 $41 $4D $45 $20 $4F $56 $45 $52 $21 to $0400+1140+15 through $0400+1140+24 -- byte values matching the ASCII string "GAME OVER!" (using the game's custom charset at $2800).
$5FBB is called via $6BD0 after every death-fall (NOT just final game-over) Only caller of $6BD0 is $5C29 (end of $5BD1+ template-restore path). NOT called from every death. CORRECTION: $5C29 is at the end of $5BD1's fall-through path (reached only when $5CF1 >= 3).
$5CF1 is a 4-phase animation state machine ($5BBB dispatcher) Verified by tracing all $5CF1 writers: $5B2F sets to 0; $5C6F $5C9C $5CDF each INC at the end of phase 0/1/2 finalizers. So a fare sequence cycles 0->1->2->3.
$5C2C is the drop-off animation ($5CF1=0), not death-fall Verified by emulator trace at row $D3 -> 642 instructions -> cab at $D4, BCD-add (+50 via $43C9) and hudInit called. Routine RTS-es cleanly, does NOT JMP to $6BD0 from here.
$5B07 is "game-over reset", NOT "deathTuneSetup" The JSR $6283 with A=$24 decompresses scene 24 (title screen), not a song. My earlier "song 24" label was wrong: $6283 is the bank-switch wrapper around $9656 (the scene decompressor), and $24 is the scene index. Verified by checking the scene pointer table at $9600 -- scene $24 is the title.
$71CE (fares done) INCs ONLY at $6BD6 grep finds only one INC ($6BD6) and one STA ($5EE9 = 0 at game init).
$7213 (fare target) is player-selectable 1..4 at title Writers: $48AF sets to 1 (default); $5310-$5328 is a title-screen joystick UI -- RIGHT INCs, LEFT DECs, ANDed with #$03 to clamp 0..3 (= 1..4 with +1 baseline).
Game-end goes to $5EC4 (gameInitContinue, restarts to title) $6BE1: JMP $602F, $602F: JMP $5EC4. So Space Taxi's "level complete" just resets the game -- no inter-level progression in the original.
romToLevel.py fareCount and the (0,0) self-loop were BOTH fabricated Deep disassembly (padHoverSetup $6537, padDetect $64CC, $6CB8) proves there is NO static fare list: spawn pad AND destination are RNG-chosen at runtime ($4080) in [1..padCount]; when all pads are used the destination is the $0B "UP PLEASE!" transporter sentinel. Fare count = $7213 (the GAME-VARIATIONS "cabbies" setting), global not per-level. FIX: romToLevel emits fareCount=0; the port (stPassenger.c) ports the runtime RNG selection. Empirically verified in DOSBox: land->board->"up please!"->fly up through the top-wall transporter->fare delivered (+5)->advance to level B.
romToLevel.py pad tileX/tileY had NO VIC origin subtraction (pads off-field) Pad slot bytes are VIC sprite-register coords. FIX (verified exact on 4 pads across scenes A/B, matched to live screen RAM): tileX=(x1-24)//8, tileY=(padRow-36)//8 (36 = sprite-Y origin 50 - 14px landing-gear). Level A pad: was tileX13/tileY25(off-field), now tileX10/tileY21 (on the rendered pad). Also the taxi spawn was hardcoded (18,2); real spawn is $7D5B-5F (level A = tile 30,3).
Full $5F40 main-loop body verified: 25 JSRs in exact order matching MECHANICS.md With stubs for KERNAL $FFE1, $$404B (vblWait), $44EF (waitTickSpin), $44D2 (waitVoicesIdle), and $7D65 = $80 so $6BE7 doesn't early-exit: 716 instructions, 25 depth-1 JSRs captured in documented order.
$4BF8 (CIA1 DDR setup) is called every main-loop iteration, not "once" Verified by trace: appears in the depth-1 chain at PC $5FB5. Setting CIA DDRs each frame is wasteful but harmless.
Emulator now covers all opcodes used by the main-loop body Added INC/DEC absx/zpx, ASL/LSR/ROL/ROR zpx/absx, LDA/STA/AND/ORA/EOR/ADC/SBC/CMP indx, CPX/CPY zp. The main loop runs to completion through these instructions.
Passenger-spawn flow $660B advances state Verified by tracing $660B with $71CC = 1: $715C 0->1 (fare slot incremented), $7163 0->1 (death stage advances to "passenger appearing"), $7176 (sprite-1 passenger X) changes. JSR chain shows $4080 (RNG) and $6537 (padHoverSetup) called.
Flame is sprite 2, NOT sprite 1 (correction) $6D6A flameSpriteUpdate writes $7177 (= $7175 + 2 = sprite 2 X) and $7199 (sprite-2 ptr). Sprite 1 ($7176, $7198) is the active passenger sprite, set by $660B and other passenger-spawn paths. Earlier MECHANICS.md sprite-role table was wrong.
$619B cab cel selection: LEFT/RIGHT only writes when held, otherwise preserves $7197 7 input/parity combos tested: LEFT held -> base $DC (or $DD with parity); RIGHT held only when NO LEFT -> base $C0 (or $C1); no L/R held -> $7197 unchanged. Default-RIGHT facing.
Pad table byte 5/6 are PASSENGER spawn coords (not "anchor X" as earlier) $662E: LDA $7D0F,X -> $7186 (passenger X-frac); $6634: LDA $7D10,X -> $7176 (passenger X col). My "second X for cab-stop snap" reading was wrong; these are the spawn position for the passenger sprite on the pad.
Pad table byte 7 has NO runtime readers grep -nE "11 7D" finds zero readers in the live disassembly. The $C2/$C4 observed values are dead. My earlier "ground vs elevated pad style" interpretation was speculation the disassembly doesn't support.
Stage 5 $6739 is pad-color-alt + zero-death-flag Verified by direct call: 11 instructions. $7167 stays 0. $DBDC: $FF -> $0B and $DBB4: $D9 -> $07 -- color RAM writes via JSR $6877 (padColor_passOff).
Custom charset at $2800 is ASCII-compatible Decoded the 8x8 bitmap glyphs for $47/$41/$4D/$45/$4F/$56/$52/$21: each renders as the corresponding Latin letter / '!'. So bytes spelling "GAME OVER!" in ASCII really do show up as readable "GAME OVER!" on screen.
Level K extended physics: cab rises ~50 px in 60 ticks no input Y position: $A000 -> $6DF6 over 60 ticks. Net -12810 sub-units = -50 px (moves UP, anti-gravity confirmed at scale). Expected per gravity-integration math: -12390. The 420-unit discrepancy = exactly one frame's vy of -420 (integration-order detail; vy applied before update on first tick).
$4253 sprite-shadow marshal: writes X,Y interleaved to $71A7-$71B6 Tested with per-sprite X = $A0..$A7, Y = $50..$57. Result at $71A7 = A0 50 A1 51 A2 52 ... A7 57. Matches MECHANICS.md format.
$4293 sprite-shadow flush: writes 5 register blocks to VIC Verified per-register: $D000-$D00F = $10-$1F (pos), $07F8-$07FF = $D0-$D7 (ptrs), $D027-$D02E = $E0-$E7 (colors), $D010 = $55 (X-MSB), $D015 = $FF (enable).
$42E9 SFX-load takes a 9-byte program, not 7 Verified with 9-byte program: byte 8 is voice index (read FIRST), bytes 0-6 written to $D400+vi*7..+6, byte 7 stored to $7216+vi (release timer), byte 4 ALSO stored to $7218+vi (ctrl mask). Earlier MECHANICS.md "7-byte program, voice index then 7 bytes" was wrong.
$4080 RNG with X=100 produces non-uniform distribution 100 trials: all in [0..99], mean ~61, ~16-cycle repeat pattern. Biased toward higher values. C64 raster-register based, not uniform.
$63DD fireButtonEdge fires SFX only on rising edge 4 cases tested: on-pad RTS; airborne+no-fire RTS; airborne+fire-held no-op; airborne+fire-rising = 8 SID writes ($42E9 SFX load) + $7197 parity toggled + $71BD latch set.
$4FCB runStopWatcher branches verified idle (no key): 12-step RTS. RUN/STOP key ($028D bit 0): kills SID voices + $7222 = $80. post-mortem flag: 6-step early RTS. game-mode flag: enters wait loop at $5006 checking joystick.
Stage 1 $665F death branch: $7163 1->8, DEC $715C Verified: with $7164=1, walked $7198 down to $C7, $7163 went 1->8 (jumps to stage 8), $715C decremented (5->4).
Stage 6 $6742 scoring: $07C2 += 5 and $7163 6->7 After 2 ticks with $7198 $C8 -> $C7: HUD $07C2 = $66 $66 $66 $6F $77 $74 $74 (= "___5. " = +5 at pos 3), $7163 6 -> 7. Score confirmed +5 per fare.
$6F18 takeoffSetup gates on $7215 >= 2 $7215 < 2: 7-step early RTS, zeros $7163. $7215 = 2: 304 steps, sets $7163 = 5 (takeoff stage), $7D8E = 1, $716D = 1, $71CD = 1.
$6888 taxiSpawnInit: copies $7D5B-$7D5F -> taxi pos, zeros vels, sets cel Per-level spawn at $7D5B-$7D5F ($80 $40 $00 $30 $60) copied to $7D61-$7D65. Velocities cleared. $7197 = $C0 (default cab cel). $71C7 = $7D60 = $AA (fuel/max copy).
Animation main loop at $5B85 is DEAD CODE in gameplay Original .prg has JMP $5B85 at $FCB5 (per full-disasm.lst). raw.bin (live runtime) has $08 $AB $28 at $FCB5 -- DIFFERENT bytes, meaning runtime patched/overwrote that JMP. So the $5B07-$5BBA animation main loop was load-time / startup code that got replaced. NEVER reached in active gameplay.
Custom charset is ASCII-compatible Decoded the 8x8 bitmap glyphs at $2800 + char*8 for "GAME OVER!": each renders as recognizable Latin letter. ASCII text in screen RAM displays correctly.
$704E hudArchive copies 7 bytes from $07C2/$07E0 to $71D3,X*8 / $71F3,X*8 With $7214 = 2 (fare idx -> X = 16), $07C2 = AA BB CC DD EE FF 11 and $07E0 = 22 33 44 55 66 77 88: after call, $71E3 = AA BB CC DD EE FF 11 00 and $7203 = 22 33 44 55 66 77 88 00. Per-fare 8-byte slot stride confirmed; only 7 bytes written, 8th stays at prior value.
$43A5 hudInit copies template $43B1-$43B7 -> $07E0-$07E6 After clobbering $07E0 with $FF * 7, calling $43A5: $07E0 = $66 $66 $66 $74 $77 $74 $74 matching template byte-for-byte.

VERIFIED against the C64 in VICE: every screen (2026-10-06)

tools/stsim/gate.sh c64 rides every screen -- A..X and the MYSTERY SCREEN, Y -- on the original disk in VICE (c64trace.py) and on the port's simulation (stsim), and the two per-tick traces are byte-identical: the cab's position and velocity, pad, fare stage, crash phase, input, sprite 0 and 1 shadows, the shown sprites' positions and enable mask, the demo playback offset and timer, the RNG, fuel, fare slots, destination, the score, fare and fuel rows, border and background, and FNV hashes of all of screen RAM, color RAM and the charset. gateHashes.txt is the md5 of each C64 trace, so make check holds the port to the real machine without an emulator.

A ride is the attract demo's machinery driven to every screen: the demo slots ($50C1) name the screen, the playback buffer ($0902) holds the same 640 bytes the sim plays, the screens-played count ($7215) makes it the screen's place in the 24-hour shift (so every screen after B starts with the takeoff fare, and Y with the finished-shift HUD), and a finished wreck takes the real game's path ($6B52 -> $6B64: a cab lost, the cab respawned at $5F27) instead of the demo's return to the title. H, T, W and X ride the C64's own attract recordings; the other 21 ride streams searched for by tools/stsim/recorder.

What the comparison found wrong in the port, each fixed from the asm:

Fix Evidence
The descent lamp (fuelBarHud) lit by the wrong rule $6419: hi byte of $714F negative -> cyan, zero with $714E bit 7 -> yellow, positive -> red on row 24 (X=$28)
Collisions saw one frame; the latch holds two $6966 reads two frames after the last read ($404B x2), so both the old and new sprite positions are in the read
Only cab pairs were latched Hooks test AND #$F8 (G $7E35, dispatch $69F9): level G re-hops when the flame touches an orb
Hazards' background bits were wiped when the cab hit too bg = 1 replaced the hazards' $D01F bits; now OR'd
The older frame's $D01F used the previous read's picture VICE: at tick 2743 of level P, $D01F was still $00 at $5F97 -- the windsock at $0677 flipped before the wait, so the melting flake no longer touched it
Level O had no gaps in its barriers, rolled the wrong glyph, ran at the wrong hook prelude0 $7D9C blanks 4 x 8 cells; $7E6E rolls $2BE0 = glyph $7C; per-tick is $7D6F
Level R's maze flip missed two cells $7D9C writes $0709..$070D (five cells, not three)
Level K's and U's contact verdicts $7DC4 LDA #$FF (kill, no wreck); $7DDA LDA #$00 (safe)
Level V scrolled every tick, columns 0..38, silently $7DA6: every 16th tick (AND #$0F), columns 1..38 ($7E0E/$7E35), SFX $7D9D
Level E grew its beanstalk only when the fare wanted UP -- backwards $7DD5: CMP #$0B; BNE grow, RTS when UP
Level I drew its bullet's climb and direction in the wrong order; hires bullets $7E21 rng(2) before $7E2F rng(4); prelude1 $7DB1 sets $D01C = $FF
Level Q cycled cels every tick, hires, and never buzzed $7DFF every other tick; prelude1 $7D9B $D01C = $FF; $7E55 buzz SFX with rng(100) while $7217 is 0
Voice release timers missing $42E9 arms $7216+voice, $4320 counts down, $6C85 sets 1, $6906 clears; read by Q and Y
A landing's event never voiced; wrong-pad landings kept the fare $61BD: event 2/3 runs $43A5 (meter lost) and speaks $6C7A/$6C7D; $6C7F clears $71CD
Respawn re-ran the level's first prelude and the takeoff fare $6BCD JMP $5F27 skips $6946, $7D66, $6F18: a waiting passenger stays put across a crash
The message blank was 11 characters $6C38 is 12 spaces; the takeoff text was 13 ($6C1E is 12)
Level F (TAXI PONG) and Y (MYSTERY SCREEN) had no hooks at all F $7D9C/$7DDB; Y $7D9B/$7E02/$7E21, its program running on to $80CA (STL5 ships $7D98..$80CA); Y's sprites come from extra scene $23
The gate's own start differed from the C64's $61FB wraps the turn and counts the screen before $62F0 (the port's game did; stsim did not)

Known and not modeled: a tick whose work overruns a frame (level V's row shift, speech) flushes the sprites mid-frame, so a collision at the top of the screen can reach the C64's read a tick later than the port's (MECHANICS.md, "Ticks that overrun a frame"). Found on a level-V ride whose cab hit the top border on a shift tick (VICE: flush at raster 58); the gate's V ride does not do that.

UNVERIFIED (acknowledged, not yet proven)

Claim Why unverified
$715C is "fare slot count" not lives INC at $6559, DEC at $6B7B etc., scene-load zeroes it. Semantics still unclear; my "fare slot" guess hasn't been confirmed against gameplay observation.
Bonus score blobs $43C1 (+95) and $43C9 (+50) fire on specific gameplay events The call sites ($5C82, $5D03) are in the $5BBB-dispatched anim and a per-passenger-counter decrement; the gameplay scenarios that put $5CF1 and $71CF,X in the necessary states haven't been replayed in the emulator.
In-cab score $07C2 ever gets reset / committed to a persistent score Only writers are BCD-add (during stage 6) and sceneLoad restore (from $71D3 backup). The accumulation logic is in the backup tables, not a global score. The port simplifies this to a uint32_t game->score.
Passenger pickup mechanism in the C64 The RTS-without-death path at $69B3 requires $71CA & 2 (sprite-1 bit) ALSO set, which is a 3-way sprite collision. Not yet reproduced.
$7D75 trampoline ever gets patched at runtime No writers found via grep. The "patches on pickup" theory was wrong. Possibly the trampoline is set ONCE at game-load via the bank-switched code at $9656 (under-ROM), which my emulator can run but I haven't watched specifically for $7D76/$7D77 writes during decompress.
Initial parity bit value during normal gameplay The state on level entry isn't traced; depends on what code sets $7197 between title and gameplay.

TRACE INFRASTRUCTURE

  • stuff/trace.py: instrumented wrapper around cpu6502.Cpu6502. Tracer.from_dump('raw.bin').call(addr) runs a subroutine and snapshots memory + JSR chain.
  • assets/cpu6502.py: minimal 6502 emulator. Sufficient for all routines tested above; throws NotImplementedError for unsupported opcodes (none hit so far in gameplay paths).
  • assets/dumpScenes.py + stuff/dumpAllLevels.py: pre-existing scene-decompress drivers.
  • assets/romToLevel.py: emits the .dat files from raw.bin via the same emulator.

METHODOLOGY (for future verification sessions)

For each new claim I want to commit to MECHANICS.md:

  1. Set up the pre-state explicitly. Don't assume registers/memory — poke_byte everything that affects the routine's behavior, plus document why each pre-state value matters.
  2. Call the routine via Tracer.call(addr). Catch failures (NotImplementedError, runaway loops) and reduce scope if it doesn't return cleanly.
  3. Diff memory. Use tracer.memory_writes() to see exactly what changed. Don't trust label names.
  4. Verify expected branches taken. Use instr_log = True for routines where control flow matters.
  5. Document the trace command in this file so the claim can be re-verified later.