88 KiB
Space Taxi Mechanics (research notes from C64 disassembly)
Source: VICE memory dumps in stuff/spacetaxi/ decoded via the
dis6502.py / disLive.py helpers. Full live-RAM listings are at
stuff/spacetaxi/live-level1.lst (40,069 instructions) and
live-raw.lst (titlescreen snapshot). This doc captures observed
behavior in plain English to drive the JoeyLib port without dragging
the original 6502 source into chat or comments.
Memory map (post-load runtime)
| Range | Contents |
|---|---|
$0801-$1AFC |
Loader + initial data (looks data-like, not code) |
$2800-$2FFF |
Custom charset (256 chars x 8 bytes mono) |
$3000-$37FF |
Sprite bitmaps (referenced by $07F8-$07FF ptrs) |
$4000-$6FFF |
Game code (physics, input, level logic) |
$7000-$7DFF |
Game state globals + per-level data table |
$CB00-$CFFF |
Music engine + tracker per-voice state |
$CF52-$CFC0 |
Custom IRQ handler |
$0400-$07E7 |
Screen RAM (40x25 char codes) |
$D800-$DBE7 |
Color RAM |
Input handling
- Per-frame routine starts at
$6032(zeros velocity), reads$DC00at$6040, appliesEOR #$FFso bits = 1 when pressed, stores processed mask to global$7169. - Bits in
$7169after EOR:0=UP 1=DOWN 2=LEFT 3=RIGHT 4=FIRE. - At
$6056-$6065, if screen-RAM sentinel$07A6 == $7Bthe dispatcher masks$7169to 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$71BDlatch. On press, toggles$7197 bit 0(cab anim parity) and triggers SFX from pointer$6410via$42E9. Likely the bonus-press to advance score.
Velocity model (CORRECTED -- earlier "template velocity" note was wrong)
The physics is acceleration-based, not constant-velocity. Stick input sets an instant per-frame acceleration. That acceleration adds to a persistent velocity accumulator, which adds to position each frame. Release the stick and velocity persists -- the cab drifts. Push opposite to decelerate. Gravity is a constant acceleration always applied to Y.
Variables (named for what they actually hold)
| Var | Meaning |
|---|---|
$7148/49 |
Per-frame X acceleration (the "instant velocity" from input) |
$714A/4B |
Per-frame Y acceleration |
$714C/4D |
X velocity accumulator (16-bit signed) -- persists across frames |
$714E/4F |
Y velocity accumulator |
$7D91/92 |
X-accel magnitude template (= 14 in level 1) |
$7D8F/90 |
Y-accel magnitude template (= 14) |
$7D95/96 |
X gravity (= 0 -- no horizontal drift) |
$7D93/94 |
Y gravity (= 1 -- constant downward acceleration) |
$7D61/62 |
Persistent X position, 16-bit fixed-point |
$7D64/65 |
Persistent Y position |
$7D63 |
1-bit X fractional carry |
$7185 |
Mirror of $7D63 |
$7175 |
Cached high byte of Xpos (= pixel column) |
$717D |
Cached high byte of Ypos (= pixel row) |
Per-frame algorithm ($6032, full trace) -- VERIFIED via emulator
Verified by running $6032 through the 6502 emulator with various
joystick / sprite-ptr states. See stuff/spacetaxi/trace.py.
1. Clear instant accel: $7148/49 = 0; $714A/4B = 0 [$6032-$603F]
2. Read input: $7169 = $DC00 EOR $FF [$6040-$6045]
3. Menu-mode test: if $07A6 == $7B: $7169 &= $10 (FIRE only); jump to vy [$6056-$6065]
else: fall through
4. Horizontal accel gate (this branch was missing from my earlier write-up):
if $7197 & 1 == 0: [$606F-$6076]
(a) $7148/49 = $7D91/92 (xAccel template, default +)
(b) if $7169 & $08 (RIGHT) : keep positive [$6088-$608A]
(c) else (LEFT) : two's-complement negate [$608C-$60A2]
if $7197 & 1 == 1: [$6190-$6198]
$7169 &= $13 (mask out L/R bits) -- horizontal accel SUPPRESSED this tick
5. Vertical accel: [$60A4-$60D3]
if $7169 & $03 (U/D):
$714A/4B = $7D8F/90 (yAccel template)
if UP bit set: negate
6. Integrate accel -> velocity: [$60D6-...]
$714C/4D += $7148/49 + $7D95/96 (X vel += X accel + X grav)
$714E/4F += $714A/4B + $7D93/94 (Y vel += Y accel + Y grav)
7. Integrate velocity -> position:
$7D61/62 += $714C/4D (Xpos += X vel)
$7D64/65 += $714E/4F (Ypos += Y vel)
8. Update caches:
$7175 = high byte of $7D61/62 (pixel col)
$717D = high byte of $7D64/65 (pixel row)
Verified single-tick results from emulator runs with level-A templates (xAccel=$0E=14, yAccel=$0E=14, xGrav=0, yGrav=1) and a zeroed velocity accumulator:
| Input | $7197 | ax | ay | vx after | vy after |
|---|---|---|---|---|---|
| none | $C0 | 0 | 0 | 0 | +1 |
| RIGHT | $C0 | +14 | 0 | +14 | +1 |
| LEFT | $C0 | -14 | 0 | -14 | +1 |
| UP | $C0 | 0 | -14 | 0 | -13 |
| DOWN | $C0 | 0 | +14 | 0 | +15 |
| RIGHT | $C1 | 0 | 0 | 0 | +1 |
| RIGHT+UP | $C0 | +14 | -14 | +14 | -13 |
Asymmetric gating: only X accel is gated by $7197 & 1. Y accel
fires regardless. So when the sprite-ptr flicker bit is 1, the cab
can still go up/down but not left/right.
When does the parity bit flip during gameplay? Hot-grep for
EOR #$01 ... STA $7197:
$6A93/$6A95-- only runs in collision-phase 1 (during death)$63FF/$6401--fireButtonEdge, fires on FIRE button rising edge
$619B (called every frame in gated dispatch) WRITES $7197 but
PRESERVES the parity bit (ORA $61 where $61 holds the captured
LSB). So during normal hover-fly gameplay, $7197 parity changes
only when the player presses FIRE.
This means: in stretches of gameplay where FIRE isn't pressed, the
parity bit is fixed -- so the cab either has horizontal accel every
frame (parity = 0) or never (parity = 1). Effective horizontal
acceleration rate depends on the live state of $7197 & 1. I have
not yet traced what sets the initial parity bit on level entry, so
the steady-state value during ordinary play is currently unknown.
The 16-bit add helper at $4072 is the integration step, called
12 times across the routine (vx+grav, +dx_accum, +Xpos and the Y mirror).
Inputs at zero-page $61-$64, output back to $61/62.
The fractional carry at $6124-$6132 adds an extra +1 to the X step
every other frame when the high byte's carry chain dictates --
gives sub-pixel granularity without floating point.
Why the dump showed dx_accum = 84, dy_accum = -66
At dump capture time the cab was mid-flight, drifting right (vel +84 sub-pixel-units/frame) and rising (vel -66 sub-pixel-units/frame in screen-coords where +Y is down). Confirms the velocities accumulate and persist across frames.
Templates and gravity per level
Per-level writers re-set $7D8F-$7D96 at level init ($5BD4) so
different levels can have different gravity / handling. Level 1 uses
{X-accel 14, Y-accel 14, X-grav 0, Y-grav 1}. Different levels may
have stronger gravity, sluggish accel, etc.
The templates are saved on death ($5A9E-$5ABC copies $7D8F-$7D96
into a cache at $5CE9-$5CF0) and restored on respawn ($5BD1-$5BFE
copies the cache back). This is part of the trampoline disable/enable
flow described in "Trampoline patching" below.
Physics is called from 3 places
$6032 is a generic motion subroutine called from:
$5F45-- main gameplay (gated by$7164 == 0= no collision dispatch)$5C2C-- death/fall animation loop (exits when$717D == $D4= row 212)$5C91-- takeoff animation loop (exits when$717D < $87= row 135)
Same motion math is reused; only the exit condition differs.
Screen-edge behavior -- BOUNCE, not wrap ($6AED)
$6AED reflects X-velocity at the screen edges instead of wrapping
or stopping. Two branches:
$6AED if $7185 == 0 (low fractional, going left):
if vx-hi ($714D) < 0 AND col ($7175) < $17 (= 23):
negate ($714C/$714D) ; EOR #$FF, INC for two's complement
$6B17 else if $7185 != 0 (going right):
if vx-hi ($714D) >= 0 AND col ($7175) >= $41 (= 65):
negate ($714C/$714D)
So crossing column 23 going left, or column 65 going right, inverts vx. The cab bounces off the screen edges like a bumper. JoeyLib port currently clamps with vx=0 -- needs updating to match.
Per-level data table ($7D00-$7D60)
Per-level data lives in this 96-byte region, written by the level
loader (the $6283 bank-switch wrapper calls into $9656 which
runs in all-RAM mode and reads level data from RAM beneath the
Kernal/I/O area). Layout derived from readers:
| Offset | Use |
|---|---|
$7D09 |
Special-pad sentinel: $6E2E branches when active |
| pad index ($7150) matches this | |
$7D0A,X*8 |
Pad table base. Each slot is 8 bytes: |
,X+0 |
pad data E (read by $645C / $67E3 setup) |
,X+1 |
pad data F |
,X+2 |
pad-X bound2 high |
,X+3 |
pad-X bound2 low |
,X+4 |
pad-X bound1 high |
,X+5 |
pad-X bound1 low |
,X+6 |
pad row (compared with $717D = taxi row) |
,X+7 |
(reserved / decorative?) |
$7D5A |
Pad count (number of slots used in the table) |
$7D5B-$5D |
Taxi spawn X position: lo, hi, frac |
$7D5E-$5F |
Taxi spawn Y position: lo, hi |
$7D60 |
Fuel max (or initial pad-cell count) |
Pad detection ($645C)
Gated routine, called every frame from the post-tick chain. Only
runs when $7150 == 0 (airborne) and the anim-parity bit is set
and Y velocity high byte is zero (slow descent). Then:
X = $7D5A (pad count); decrement X
loop:
if $717D (taxi row) != $7D0E,X*8 (pad row): try next pad
set $61/$62 = $7D62/$7D63 (taxi Xpos)
set $63/$64 = $7D0B,X*8 / $7D0A,X*8 (pad X bound)
JSR $6565 (16-bit compare); if Xpos < bound1, skip
set $63/$64 = $7D62/$7D63 again (taxi Xpos)
set $61/$62 = $7D0D,X*8 / $7D0C,X*8 (pad X bound2)
JSR $6565; if bound2 < Xpos, skip
$7150 = $7151 = X+1 ; mark on-pad
$71CD = 0
compare X+1 to $7D8E (active sprite idx); if equal, jump $64F8
; (continuation handles death-counter, music, etc.)
So pad landing is detected by row match + column bounds
overlap, indexed off the per-level pad table. The result is
stashed in $7150 (1-based pad index, 0 = airborne).
Game state sentinel
$07A6 is the top-right cell of screen RAM. Used as a one-byte
state tag distinguishing menu mode ($7B) from gameplay. Many
input/handler sites branch on this value. Title/instruction/score
screens write $7B there; level start writes something else.
Crash / pad / passenger systems (mostly traced)
The original game leans entirely on VIC-II hardware collision registers, no per-cell screen-RAM walking:
$D01E= sprite-to-sprite collision latch$D01F= sprite-to-background collision latch (reading either also clears the latched bits)
Three call sites use them:
| Site | Role |
|---|---|
$6920 |
clear-only (reads to reset latches, JSRs anim tick) |
$6946 |
death/transition: STA #$00 -> $D015 (sprites off), |
| clear collision/menu/animation state | |
$6966 |
per-frame dispatch: latch both regs, then jump to |
$6A72/$6B24/... based on $7164 phase |
The phase byte $7164 is the dispatcher; $7165/$7166 form a
frame-rate gate (count down each frame, reload when zero) so each
phase handler runs every N frames, not every frame.
Collision dispatch trace (VERIFIED via emulator)
Verified by running $6966 from Tracer.from_dump('raw.bin') with
hand-set $D01E/$D01F values and observing the resulting $7164
phase and $7197 cel transitions.
$D01E (ss) |
$D01F (bg) |
Phase after | $7197 |
$721D |
Outcome |
|---|---|---|---|---|---|
| $00 | $00 | 0 | $C1 | - | no change |
| $00 | $01 | 1 | $CC | $01 | DEATH |
| $09 | $00 | 1 | $CC | $01 | DEATH (passenger 3 + sprite 0 collision) |
| $09 | $01 | 1 | $CC | $01 | DEATH (bg wins) |
| $08 | $00 | 0 | $C1 | - | no change (sprite-sprite without sprite 0 bit) |
The $7D75 trampoline is STATIC in both raw.bin (under-ROM
capture) and mem0000-level1.bin. The bytes are $4C $9A $7D
(JMP $7D9A) where $7D9A is $60 (RTS). So the trampoline
ALWAYS returns A unchanged. Grep across the entire disassembly
finds NO writers to $7D76 or $7D77. The "trampoline gets patched
to allow pickup" theory I had earlier (in an earlier draft of this
doc) was wrong -- the operand is fixed.
Demonstrated mechanism via emulator: manually plant LDA #$00; RTS
at $7DA0 and patch $7D76/$7D77 to point there. After:
| Trampoline target | After taxi+passenger collision |
|---|---|
$7D9A (RTS) |
phase=$01, sprite $C1->$CC, DEATH |
$7DA0 (LDA #$00) |
phase=$00, sprite $C1, no death |
So the mechanism EXISTS but isn't used in any state we've captured.
The RTS-without-death path at $69B3 requires $71CA bit 1
(sprite 1 = flame) ALSO set, which is a multi-sprite collision
involving cab + flame + passenger. Possibly that's how pickup is
detected -- the C64 game arranges for the flame sprite to be
involved in a 3-way collision when boarding. Not yet verified.
For the JoeyLib port: my explicit pickup logic ("if cab is landed on the passenger's pad, board them") is a clean simplification. The C64's mechanism via 3-way sprite collision is too entangled with hardware-specific behavior to reproduce directly.
Phase 1 handler ($6A72) -- fall + thrust SFX
This handles in-flight gameplay. Each tick it:
- Reads
$721D-- if high bit set (BMI taken), jumps to the phase-advance / clamp branch (player is dying). - 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. - Toggles
$7197bit 0 -- frame-parity flag for visual sync. - Integrates
$714C/$714D(per-frame dx) into$714E/$714F(sub-pixel row accumulator), +40 per frame. - Adds the integrated row delta into
$717D(taxi pixel row). - If
$717D >= $DA(218), clamps it and INCs$7164-- advances to phase 2 (the floor/pad has been reached). - Falls through to a screen-edge wrap routine at
$6AEDthat inverts$714Cwhen 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-opJSR <RTS>. - Writing
$4C(JMP abs) = trampoline enabled, jumps to whatever pre-baked operand follows.
$5AC1-$5AD0 is the trampolines-off patcher (writes #$60 to
all six opcode bytes), $5C03-$5C12 is trampolines-on (#$4C).
These bookend the death-and-respawn sequence: shut everything off,
play the death anim with most main-loop work neutered, then
re-enable for respawn.
The JMP targets in level 1's gameplay snapshot (mem0000-level1.bin):
| Slot | Called from main loop | Target | Role |
|---|---|---|---|
$7D66 |
prelude ($5F21) | $7D9C (RTS) |
post-prelude 0 |
$7D69 |
prelude ($5F2A) | $7D9B (RTS) |
post-prelude 1 |
$7D6C |
post-work ($5F8E) | $7D9D (RTS) |
per-tick 2 |
$7D6F |
post-work ($5F9D) | $7D98 (RTS) |
per-tick 3 |
$7D72 |
$6283 decompress |
$7D99 (RTS) |
level-init hook |
$7D75 |
hit dispatch ($6A03) | $7D9A (RTS) |
passenger-hit verdict |
On the title screen (mem0000.bin/mem0000-title.bin), some of
the operands point at real routines ($7E5A for slot 2,
$7E57 for slot 5). Those operands are loaded as static data
during the title-screen boot, not patched by code we've found.
The $7D75 hit-dispatch trampoline (resolved)
Called from $6A03 with A=#$01:
$6A01 LDA #$01
$6A03 JSR $7D75 ; trampoline -- may modify A
$6A06 STA $721D ; player-state byte
$6A09 LDA $71CB
$6A0C AND #$01 ; taxi-vs-background?
$6A0E BNE $6A1E ; -> death start
$6A10 LDA $5569
$6A13 BEQ $6A16
$6A15 RTS ; menu-mode-ish gate
$6A16 LDA $721D
$6A19 CMP #$00
$6A1B BNE $6A1E ; -> death start if trampoline said die
$6A1D RTS
So the trampoline's role is to decide whether a passenger contact
should kill the cab. The default $7D9A target (RTS) leaves
A=1, so $721D=1, so the BNE at $6A1B IS taken -- passenger contact
without further state would crash the cab. That's actually consistent
with the original behavior: random passenger collisions DO kill you;
you have to be on a pad in the right state for them to board.
When the per-level setup wires a different target (e.g. $7E57,
LDA #$00; RTS), A=0 after the trampoline, $721D=0, BNE not taken,
passenger contact is benign. This must be triggered during the
pickup window (passenger on top of cab while landed on right pad).
JoeyLib port doesn't need this trampoline scheme -- our pickup logic
lives explicitly in stPassenger.c.
$7D72 level-init hook
Called only via the $6283 bank-switch wrapper. Probably loaded
with a per-level "post-load setup" target by whatever generates the
per-level table. On gameplay-state dumps it's RTS.
Title / menu state ($48AD)
Entry from gameInit ($5EC1) when $5E9D != 0 (post-mortem) or
$721C != 0 (menu mode requested).
$48AD $7213 = 1 ; one fare needed
$721C = 1 ; menu mode
$7214 = 0
$7171/$7172 = 1 ; menu timers
$3F/$40 = $0900 ; script buffer
$5569 = 0 ; clear paused flag
$595C = $20
$5975 = $2A
if $5E9D:
$0900/$0901 = $00 $FF
JMP $5F02 (mainEntry)
else:
$3F = 2
JMP $5F02
After the JMP to $5F02, the main loop runs with $721C=1, which
causes the post-tick chain to behave differently (menu-only paths
in $4FCB, $6BE7). The actual title-screen DRAWING happens via
$4525 (title sprite setup) and $4861 (logo color cycle).
$4525 puts all 7 sprites at the same screen corner ($AA, $E4 / $8C),
sets their ptr to $DA ($3680), color to 7, then triggers song 8
via $CB02. This is the idle title state.
$4955 is the reverse: gate-off voice 1+2, noise off, load song
from $6F75,X (X = $716B current music index), set $5E9D=0,
JMP $5EC4. Exit-to-title cleanup.
Lives + death state
| Var | Meaning |
|---|---|
$715C |
Fare-slot count -- INC at $6559 on each new pad-hover setup, DEC at $69CA/$6B7B/$6B9E on death-finalize, zeroed at $634A on scene load. Earlier draft labeled this "lives counter" based only on the DEC sites; the INC and scene-load-zero behavior rule that interpretation out. Probably tracks "fares currently in play". The port's own lives mechanic does not derive from this address. |
$7163 |
10-stage death-anim state machine ($65C1 dispatch) |
$7164 |
4-phase collision dispatch (0/1/2/3) |
$7167 |
Death dispatch in-progress gate |
$7152,X |
Per-sprite slot table; cleared on death |
$7D8E |
Index into $7152 for the active death subject |
$71CE |
Successful-fare counter; compared with $7213 |
Death-stage dispatcher ($65C1)
Called every frame from post-tick. 10-way jump on $7163:
| Stage | Target | Purpose |
|---|---|---|
| 0 | $660B | idle RNG-gated stage advance ticker |
| 1 | $665F | stage-1 handler |
| 2 | $66B7 | stage-2 handler |
| 3 | $66DA | stage-3 handler |
| 4 | $66DD | stage-4 handler |
| 5 | $6739 | "takeoff" -- pad hover with input |
| 6 | $6742 | stage-6 handler |
| 7 | $67A3 | stage-7 handler |
| 8 | $67A6 | stage-8 handler |
| 9 | $67C6 | stage-9 handler |
Stage 0 idle ticker: $71CC decrements every frame; on hitting 0,
runs $6537 (probability gate), reads pad-table entry at
$7D0E,X*8 (the X-of-pad table) into hover positions
$7186/$7176/$717E, sets $718F=1 (hover active flag),
$7163=1 (advance to stage 1), $715D = $715E.
So death-stage 0 is the between-level pause ticker that picks a random next pad and transitions into the takeoff sequence.
Per-tick handlers detailed
$67E3 -- postTickStateGate
State-dependent setup that runs every frame. Reads $7150 (active
pad). If 0 (airborne) RTS. If non-zero, dispatch by $7163:
| Stage | Action |
|---|---|
| 0,1,3,6,7 | RTS (idle) |
| 2 | INC $7163, save current col/frac to $7160/$715F, fall to $6808 |
| 5 | If $716F=0 (bob timer done): INC $7163, $718F=1, |
save col/row, $7198=$CB, $715D=$715E, build |
|
takeoff art pointer from $7D0F,X*8/$7D10,X*8 |
|
| $6808 | $7167=1, JSR $6866 (pad-passenger-on colors) |
The "$6866 vs $6877" pair is the pad indicator color flash:
$6866 writes $0B to $DBB4/$DBB5 (left edge) and $02 to
$DBDC/$DBDD (right edge); $6877 swaps them. Toggled by stages.
$619B -- taxiSpriteCelSelect
LDA $7197; AND #$01 -> $61 ; isolate frame-parity bit
LDA $716A; AND #$04 -> Z? ; right input?
BNE -> A=$DC ; flame-on base ptr
LDA $716A; AND #$08 ; left input?
BEQ RTS ; no input -> keep current ptr
A=$C0 ; (some other base ptr)
ORA $61 (parity bit) ; mix in the flicker bit
STA $7197 ; new sprite-0 ptr
So the cab's sprite ptr is selected as base ($C0 or $DC) OR'd with the per-frame parity bit. Base $DC = "flame-on" cel (with engine exhaust visible), base $C0 = "flame-off" cel. The +1 flicker is the EOR #$01 from phase-1 handler at $6A95, which alternates the exact cel address. NOTE: the C-port doc said "2 cels"; that's actually 4 cels organized as 2 bases x 2 flicker variants.
$61BD -- passengerEventDraw
If $716F != 0 (a passenger event just happened): peek $71CD
(event type) and dispatch:
$71CD |
Event | Pointer source for $6C7F |
|---|---|---|
| 1 | (drop-off?) | $6A/$6B = $6C6C |
| 2 | (pickup?) | $6A/$6B = $6C7A |
| other | (takeoff?) | $6A/$6B = $6C7D |
$6C7F is "render text/scoreboard": marshals shadow, flushes,
then draws indirect text. So this routine surfaces a HUD blurb
based on what just happened.
$63DD -- fireButtonEdge
LDA $7150; BNE RTS ; only when airborne
LDA $71BD; BNE -> store fire ; if previously fired, latch update
LDA $7169; AND #$10 ; FIRE bit
BEQ RTS ; not pressed -> done
STA $71BD ; new latch = $10
EOR $7197 #$01 ; flicker cab sprite
$68/$69 = $6410 ; SFX pointer
JSR $42E9 ; play SFX
So fire-button is debounced; on the rising edge, plays a SFX from
$6410 and flickers the cab sprite. This is likely the bonus-fare
or quick-stop bell.
$6D6A -- flameSpriteUpdate
LDA $716A; BEQ -> turn off ; no input held -> noise off
LDA #$81; STA $D412 ; voice 3 noise ON
CLC
LDA $7175; ADC #$FE -> $7177 ; flame col = taxi col - 2
LDA $7185; ADC #$FF -> $7187 ; flame col frac with borrow
LDA $717D; STA $717F ; flame row = taxi row
LDA $716C; EOR #$01 -> $716C ; parity toggle
BEQ -> $6DAA ; on every other frame
LDA $716A; TAX
LDA $6DB0,X -> $7199 ; sprite-1 ptr = (per-direction cel)
$7190 = 1 ; flame visible
RTS
$6DA5 (no input):
A=$80; STA $D412 ; voice 3 OFF
$7190 = 0 ; flame hidden
So while input is held, sprite 1 is repositioned to (taxi col - 2,
taxi row) and pointed at a per-direction cel from table $6DB0.
Plus voice 3 plays noise (jet sound). When input released, sprite 1
disappears and noise gates off. Flame is a separate sprite (not
just a color flash).
The $6DB0 table is the engine-flame cel pointer table
(direction-indexed, 8 entries).
$6E23 -- padPaymentAnim
Per-frame check of pad payment HUD:
LDA $7150
CMP $7D5A ; on last/sentinel pad?
BEQ $6EAA
CMP $7D09 ; on special pad?
BNE $6E36
LDA $07B1 ; HUD char at $07B1
CMP #$66
BNE RTS ; only animate if char is currently $66
$68/$69 = $07C2 (HUD area)
JSR $440B ; draw -> sets carry on overflow
BCC RTS
INC $71C8 ; tick mod-8
AND #$07
STA $71C8
BNE -> check stage ; not at boundary
$68/$69 = $6DBB ; SFX ptr
JSR $42E9 (SFX)
JMP $6E77
So $6E23 cycles the fare-payment HUD chars at $07C2 area every 8 frames, plays a "ka-ching" SFX from $6DBB on each boundary.
$6DFF -- padLandingBob
Misaligned in the disassembly (the byte at $6DFE eats it), but the real entry is at $6DFF:
LDA $716F
BEQ RTS ; timer idle
DEC $716F
LDA $716F
AND #$01
BNE up ; odd value -> row up
INC $717D ; row down (even)
LDA $716F
BEQ end-of-bob
RTS
end-of-bob:
$7167 = 0 ; clear death-in-progress
JSR $6877 (pad color alt)
RTS
up:
DEC $717D ; row up
RTS
So $716F is the pad landing bob timer. While it counts down,
the row alternates +1/-1 every other tick. On reaching 0, clears
the death-in-progress gate and swaps pad colors.
$6EF0 -- padLightingGate
Only acts during $7163 stages 3, 7, or 8. If $7150 != 0 (on
a pad):
LDA $71CA; AND #$02
STA $7167 ; persistent "current bg-coll" flag
BEQ $6F14 -> $6877 (pad color "off")
JSR $6866 -> pad color "on"
So when a passenger sprite touches the cab and the cab is on a pad, the pad lights up. This is the passenger boarding indicator.
$6BE7 -- levelEndCheck
LDA $028D (keyboard scancode)
CMP #$04
BNE $6BF3
LDA $5569 (paused flag)
BNE $6BFB (force end)
$6BF3:
LDA $7D65 (Y position hi byte)
CMP #$1B
BCC $6BFB ; cab is at/above spawn row -> end level
RTS
$6BFB:
PLA PLA ; abandon return chain
$D412 = $80 ; voice 3 OFF
$D404 = $D40B = 0 ; voice 1+2 control off
$7216 = $7217 = 0 ; envelope timers cleared
JSR $704E
LDA $721C
BNE -> JMP $6B52 (level transition)
JMP $5A11
$028D is the C64 keyboard scancode buffer. Bit pattern $04 might
be the F1 key or Q. So this is a manual abort path plus a
successful-level-finish check (cab reaches the top of the
screen, row < $1B = 27).
$70B1 -- passengerArrTick
LDX $7214 (active fare idx)
LDA $7223,X; BNE RTS ; per-passenger arrival timer not zero
LDA $07C3 (HUD char); CMP #$6D; BCC RTS
LDA $71CF,X; ADC #$01 ; advance per-passenger counter
STA $71CF,X
STA $7223,X
TAX
LDA #$C8 -> $0798,X ; mark slot complete
LDA #$04; JSR $44CF ; trigger anim
RTS
So per-passenger HUD slot animation; when the counter reaches a threshold, marks the slot complete and triggers a sprite anim tick.
$4FCB -- runStopWatcher
Per-frame keyboard check:
LDA $028D
AND #$01 ; RUN/STOP bit
BEQ $4FED ; not held
$D412 = $80 ; voice 3 off
$7222 = $80 ; mark thrust ended
$D40B = $D404 = 0 ; voice 1+2 ctrl off
LDA $CB81 ; voice 1 track ptr
BEQ -> JMP $4FCB ; spin until music done
JSR $44E1 ; wait IRQ tick
JMP $4FCB
$4FED (no RUN/STOP):
LDA $5E9D; BEQ -> further check
RTS
(check $721C and $DC00 for menu interactions)
$4FF8: JSR $FFE1 (kernal RUN/STOP check) -- if pressed, transition
So $4FCB is the abort to title / pause handler. RUN/STOP key kills all audio, then either loops waiting for release or transitions.
$6419 -- fuelBarHud
LDA #$0B; STA $DBA2,$DBA3,$DBCA,$DBCB ; pad color cells
INC $71BE; AND #$07; STA $71BE ; mod-8 ticker
CMP #$05; BPL RTS ; only first 5 of 8 frames
LDA $714F (Y-vel hi); BMI down ; sign-test
BEQ check_X ; Y vel = 0
A=$02, X=$28; JMP write
check_X:
LDA $714E
BMI down
A=$03, X=$00; JMP write
down:
A=$07, X=$00
write: STA $DBA2,X; STA $DBA3,X; RTS
So the fuel bar's color depends on cab Y-velocity sign (rising vs
descending vs hovering). The $DBA2-$DBCB cells form the in-flight
indicator strip.
IRQ handler ($CF52) -- music only, no animation
The IRQ does music ticks only. Sprite multiplexing, screen-cell animation, elevator updates, etc. happen in the main game loop, not the IRQ. The IRQ structure:
$CF52: save A/X/Y via self-mod into the LDA/LDX/LDY immediates
at $CF9C-$CFA1 (cheaper than stack push)
$CF5B: BIT $CBFF -- pull bits 7 and 6 into N and V flags
$CF5E: BMI / BVS branching:
bit 6 set -> skip music (frame-done guard)
bit 7 set, V=0 -> $CC28 (full silence/reinit all voices)
bit 7 clear, V=0 -> $CC58 (soft gate-off)
$CF6D: save $FC/$FD into $CBD4/$CBD5
$CF77: loop X = 0, 7, 14 (the three SID voices)
$CF7D: JSR $CC6C (per-voice tracker tick)
$CF80: $CBD6 += 7; if < 21, loop
$CF8A: restore $FC/$FD
$CF94: OR #$40 into $CBFF (mark frame done)
$CF9C: LDA/LDX/LDY (self-modified) restore regs
$CFA2: JMP $EA31 (KERNAL standard IRQ exit)
Music tracker per-voice tick ($CC6C)
The 3-voice loop driver. Each call gets X = 0 (voice 1), 7
(voice 2), or 14 (voice 3) so the same code services all three
SID voices. Per-voice state lives in $CB80+X slots:
| Var | Role |
|---|---|
$CB80,X |
track pointer lo |
$CB81,X |
track pointer hi (0 = inactive) |
$CB83,X |
per-voice "active note" flag (0 = on) |
$CB84,X |
per-voice timer (compared with $A2 tempo) |
$CB86,X |
per-voice transpose offset |
$CB9B,X |
per-voice default note duration |
$CBAB,X |
per-voice filter mask |
$CBBF-C2,X |
per-voice ADSR + pulse-width state |
$CBC3,X |
per-voice waveform / control byte mask |
$CBC4,X |
per-voice filter routing flag |
$A2 |
global tempo (cycles per tracker step) |
Each tick:
- if
$CB84,X != $A2, RTS (not time yet for this voice) - else if
$CB83,X != 0: advance timer ($CB84,X += $A2), clear active flag, gate-off the SID voice ($CBC3,X & $FE -> $D404,X) - else: fall to
$CC8Dfor note-start (now traced below)
Note start ($CC8D) -- full sequence
LDA $CB81,X ; track ptr hi
BEQ gate_off ; track inactive
STA $FD; LDA $CB80,X; STA $FC ; zp $FC/$FD = ptr
LDY = 0
LDA ($FC),Y ; read next byte
BEQ gate_off ; $00 = end of track
CMP #$80
BNE check_note ; $80 = end of song -> clear ptr + gate off
gate_off: ; $D404,X &= $FE
JMP advance_ptr
check_note:
AND #$7F ; mask high bit (effect flag)
CMP #$61 ; note count = 97
BCC normal_note
JMP $CD56 ; effect command (out of scope for now)
normal_note:
CLC; ADC $CB86,X ; transpose
CMP #$61; BCS gate_off ; clamp -- past end of freq table
TAY
LDA $CEF1,Y -> $D400,X ; voice freq LO
LDA $CE90,Y -> $D401,X ; voice freq HI
LDA $CBBF,X -> $D405,X ; attack/decay
LDA $CBC0,X -> $D406,X ; sustain/release
LDA $CBC1,X -> $D403,X ; pulse-width lo
LDA $CBC2,X -> $D402,X ; pulse-width hi
LDA $CBC4,X ; filter flag
PHP
LDA $D417; EOR #$FF; ORA $CBAB,X; EOR #$FF
PLP; BEQ skip_filter
ORA $CBAB,X
skip_filter:
STA $D417 ; filter routing
LDA $CBC3,X; ORA #$01 -> $D404,X ; waveform + gate-on
advance_ptr:
LDY = 0
LDA ($FC),Y
BMI explicit_duration
LDA $CB9B,X; SEC; ADC $A2; STA $CB84,X ; default duration
A=0; STA $CB83,X ; active flag = 0
BEQ continue
Frequency tables: $CEF1-CF52 (97 entries hi), $CE90-CEF0
(97 entries lo). Note codes are 1..97 indexing both tables.
So the music is a simple list-of-(note, optional-duration) stream per voice, with per-voice transpose and ADSR/filter state stored adjacently. No subroutines, no jump tables -- just a flat stream terminated by $00.
Two reset variants
| Routine | When called | Effect |
|---|---|---|
$CC28 |
IRQ when $CBFF bit 7 SET, bit 6 clear |
full SID reset: write $08 (test bit) then 0 to each voice ctrl, prime per-voice timer to $A2+1, clear active flag |
$CC58 |
IRQ when $CBFF bit 7 CLEAR, bit 6 clear |
soft release: STA $D404,X with bit 0 cleared on each voice (release current note's gate, leave timer/state alone) |
What the IRQ does NOT do
- Sprite X/Y register writes (those land in
$4293, called from main loop -- see "Sprite system" below) - Screen RAM animation (elevators, blinking lights, etc.)
- Game logic (taxi physics, pad detection, scoring)
- Color RAM cycling for the title (
$4861is 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:
$6D6Awrites$81(noise + gate ON) to$D412while direction input is held;$80(gate off) when released. -
Crash noise:
LDA #$81 STA $D412(noise waveform + gate on) followed byLDA #$80 STA $D412(gate off) after a short duration -- this is the noise-burst envelope. -
Note release: per-voice envelope tick at
$4320clears the gate bit of$D404/$D40Bwhen timers$7216/$7217hit zero. The timer is loaded from$7218/$7219(the SFX program byte that holds the wave|gate mask). -
SFX load:
$42E9reads 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 FIRSTRoutine 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:
- Packs the boolean array
$718E-$7195(which holds per-sprite "feet hidden" flags) into byte at$7196via 8x ROL through carry. Same for$7185-$718Cpacked into the X-MSB shadow. - 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):
$7175X,$7197ptr - sprite 1 (active passenger):
$7176X,$7198ptr - sprite 2 (flame):
$7177X,$7199ptr - 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.
$7D09and$7D5Acorrelate 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:
$48AFsets it to$01default$5310-$5328is 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
- RIGHT held (
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
$41C2from 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
$4101with 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
$9656and its helper bit-stream reader at$96A3/$964A - The scene pointer table at
$9600(28 entries, 2 bytes each) - The compressed scene data at $D000-$FCFF and $8100-$9418
- The music engine code at $CB00-$CFFF and frequency tables at $CE90 (lo bytes) / $CEF1 (hi bytes)
Scene pointer table ($9600)
24 game levels (A..X = scenes 0..23) + special scenes:
| Scene | Letter | Data addr |
|---|---|---|
| 0 | A | $D000 |
| 1 | B | $D1E4 |
| 2 | C | $D3E0 |
| 3 | D | $D700 |
| 4 | E | $D8F5 |
| 5 | F | $DB15 |
| 6 | G | $DD77 |
| 7 | H | $E075 |
| 8 | I | $E416 |
| 9 | J | $E6F6 |
| 10 | K | $E9BF |
| 11 | L | $EBCE |
| 12 | M | $EE79 |
| 13 | N | $F05F |
| 14 | O | $F251 |
| 15 | P | $F46D |
| 16 | Q | $F767 |
| 17 | R | $FA61 |
| 18 | S | $FC9F |
| 19 | T | $8100 |
| 20 | U | $8340 |
| 21 | V | $86A7 |
| 22 | W | $8943 |
| 23 | X | $8C7B |
| 24 | title | $8F7D |
| 25 | ? | $9418 |
| 26+ | invalid | (data garbage) |
Decompressor ($9656)
$9656: TXA / ASL A / TAX ; X = scene*2 (word index)
$9659: LDA $9600,X ; load LO of pointer
$965C: LDY $9601,X ; load HI
$965F: STA $964B / STY $964C ; self-mod the LDA at $964A
$9665: LDY=0; LDX=3
$9669: JSR $964A ; read 1 bit from stream
STA $93,X; DEX; BNE ; build 4-bit token
$9672: TYA; AND #$0F
BEQ $968B ; skip if zero
$9677: ... shift/copy table-build ...
$968B: STA $039C,Y ; write to output table
STA $0368,Y ; output a second table too
JSR $96A3 (read 4 bits) -> $0334,Y
$969A: INY; CPY #$34; BNE $9671
... walk output ...
It's a per-scene bitstream decoder that builds three tables at $0334, $0368, $039C from a compressed stream. Each scene blob is roughly 250-700 bytes that expands to 1000 cells of screen RAM, 1000 cells of color RAM, plus the per-level data table at $7D00.
The decompressor uses self-modification for the read pointer ($964B/$964C are operand bytes of the LDA at $964A), enabling the same code to walk any scene's pointer.
Per-level data table for all 24 levels (VERIFIED via emulator)
Extracted by running the decompressor for each scene through
cpu6502.py and reading $7D00-$7D9F after. X-accel and Y-accel
are stored separately ($7D91/92 X, $7D8F/90 Y) and DIFFER on
several levels. X-gravity ($7D95) is non-zero on levels P and U
(side-wind). Y-gravity ($7D93) is signed -- level K = $F9 = -7.
| Lvl | Pads | SpawnX | SpawnY | X-acc | Y-acc | X-grav | Y-grav | Bord/BG |
|---|---|---|---|---|---|---|---|---|
| A | 1 | $0A00 | $4A00 | $0E | $0E | $00 | $01 | $00/$00 |
| B | 3 | $8800 | $8800 | $11 | $11 | $00 | $01 | $00/$00 |
| C | 5 | $AA00 | $4400 | $12 | $10 | $00 | $02 | $00/$00 |
| D | 10 | $8800 | $8800 | $11 | $14 | $00 | $06 | $00/$00 |
| E | 9 | $FA00 | $C800 | $11 | $15 | $00 | $06 | $09/$00 |
| F | 3 | $AA00 | $6400 | $15 | $13 | $00 | $03 | $00/$00 |
| G | 5 | $AF00 | $5600 | $14 | $18 | $00 | $06 | $0C/$00 |
| H | 5 | $9600 | $3E00 | $19 | $19 | $00 | $06 | $00/$00 |
| I | 7 | $AA00 | $6E00 | $15 | $13 | $00 | $03 | $00/$00 |
| J | 6 | $1E00 | $3E00 | $19 | $19 | $00 | $05 | $09/$00 |
| K | 5 | $B400 | $5F00 | $17 | $15 | $00 | $F9 | $00/$00 |
| L | 6 | $6E00 | $4600 | $1A | $1A | $00 | $03 | $0B/$0B |
| M | 10 | $AF00 | $3E00 | $40 | $46 | $00 | $06 | $06/$00 |
| N | 8 | $AF00 | $3E00 | $12 | $16 | $00 | $06 | $00/$00 |
| O | 1 | $2E00 | $3400 | $15 | $13 | $00 | $03 | $00/$00 |
| P | 3 | $2400 | $4A00 | $15 | $15 | $04 | $06 | $0B/$00 |
| Q | 8 | $B400 | $4600 | $19 | $19 | $00 | $05 | $0B/$00 |
| R | 1 | $E600 | $3800 | $10 | $10 | $00 | $01 | $00/$00 |
| S | 5 | $AA00 | $4400 | $12 | $10 | $00 | $03 | $07/$08 |
| T | 8 | $AF00 | $C800 | $1B | $26 | $00 | $06 | $06/$00 |
| U | 5 | $AA00 | $4A00 | $18 | $18 | $02 | $02 | $00/$00 |
| V | 7 | $AF00 | $3E00 | $1E | $20 | $00 | $06 | $06/$00 |
| W | 3 | $3200 | $4600 | $1E | $1E | $00 | $03 | $00/$00 |
| X | 8 | $AF00 | $4600 | $17 | $17 | $00 | $06 | $0B/$00 |
Sprite colors ($7D07/$7D08) verified $06 (blue) for EVERY
level -- so the cab color is constant across the game; only the
border/BG palette changes.
Key gameplay-affecting observations:
- Levels P and U have side-wind: non-zero X-gravity (
$04and$02respectively). 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:
$65C1runs every frame from post-tick (stages 0-9, but only 0/1/2/4-9 are reachable; the 2->3 path is broken)$4A03runs 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)
- Bonus / tip score blobs: blobs at
$43C1and$43C9are 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. - 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.
- Menu-state script interpreter at
$48F2-$494F: consumes a byte stream from the$0900buffer. 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. - Level D's
$FFpad-style placeholder: byte-7 of all 10 pad slots is$FFin 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.