joeylib2/examples/spacetaxi/VERIFIED.md

15 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/spacetaxi/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 bug: levels A/O/R had fareCount=0 (uncompletable) Verified by parsing all 24 .dat files. Fixed: single-pad levels now emit (0, 0) self-loop fare.
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.

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/spacetaxi/trace.py: instrumented wrapper around cpu6502.Cpu6502. Tracer.from_dump('raw.bin').call(addr) runs a subroutine and snapshots memory + JSR chain.
  • stuff/spacetaxi/cpu6502.py: minimal 6502 emulator. Sufficient for all routines tested above; throws NotImplementedError for unsupported opcodes (none hit so far in gameplay paths).
  • stuff/spacetaxi/dumpScenes.py + dumpAllLevels.py: pre-existing scene-decompress drivers.
  • stuff/spacetaxi/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.