15 KiB
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 aroundcpu6502.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; throwsNotImplementedErrorfor 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.datfiles from raw.bin via the same emulator.
METHODOLOGY (for future verification sessions)
For each new claim I want to commit to MECHANICS.md:
- Set up the pre-state explicitly. Don't assume registers/memory —
poke_byteeverything that affects the routine's behavior, plus document why each pre-state value matters. - Call the routine via
Tracer.call(addr). Catch failures (NotImplementedError, runaway loops) and reduce scope if it doesn't return cleanly. - Diff memory. Use
tracer.memory_writes()to see exactly what changed. Don't trust label names. - Verify expected branches taken. Use
instr_log = Truefor routines where control flow matters. - Document the trace command in this file so the claim can be re-verified later.