2134 lines
88 KiB
Markdown
2134 lines
88 KiB
Markdown
# Space Taxi Mechanics (research notes from C64 disassembly)
|
|
|
|
Source: VICE memory dumps in `stuff/spacetaxi/` decoded via the
|
|
`dis6502.py` / `disLive.py` helpers. Full live-RAM listings are at
|
|
`stuff/spacetaxi/live-level1.lst` (40,069 instructions) and
|
|
`live-raw.lst` (titlescreen snapshot). This doc captures observed
|
|
behavior in plain English to drive the JoeyLib port without dragging
|
|
the original 6502 source into chat or comments.
|
|
|
|
## Memory map (post-load runtime)
|
|
|
|
| Range | Contents |
|
|
| ------------------- | ------------------------------------------------- |
|
|
| `$0801-$1AFC` | Loader + initial data (looks data-like, not code) |
|
|
| `$2800-$2FFF` | Custom charset (256 chars x 8 bytes mono) |
|
|
| `$3000-$37FF` | Sprite bitmaps (referenced by $07F8-$07FF ptrs) |
|
|
| `$4000-$6FFF` | Game code (physics, input, level logic) |
|
|
| `$7000-$7DFF` | Game state globals + per-level data table |
|
|
| `$CB00-$CFFF` | Music engine + tracker per-voice state |
|
|
| `$CF52-$CFC0` | Custom IRQ handler |
|
|
| `$0400-$07E7` | Screen RAM (40x25 char codes) |
|
|
| `$D800-$DBE7` | Color RAM |
|
|
|
|
## Input handling
|
|
|
|
- Per-frame routine starts at `$6032` (zeros velocity), reads
|
|
`$DC00` at `$6040`, applies `EOR #$FF` so bits = 1 when pressed,
|
|
stores processed mask to global `$7169`.
|
|
- Bits in `$7169` after EOR: `0=UP 1=DOWN 2=LEFT 3=RIGHT 4=FIRE`.
|
|
- At `$6056-$6065`, if screen-RAM sentinel `$07A6 == $7B` the
|
|
dispatcher masks `$7169` to bit-4 only (menu mode, only fire
|
|
matters). Otherwise gameplay path runs.
|
|
- Gameplay path tests LEFT/RIGHT (bits 2+3) and UP/DOWN (bits 0+1)
|
|
separately. **Fire is never tested in the gameplay path.**
|
|
- The **fire button** is consumed by `$63DD` (a post-tick handler):
|
|
edge-detect via `$71BD` latch. On press, toggles `$7197 bit 0`
|
|
(cab anim parity) and triggers SFX from pointer `$6410` via
|
|
`$42E9`. Likely the bonus-press to advance score.
|
|
|
|
## Velocity model (CORRECTED -- earlier "template velocity" note was wrong)
|
|
|
|
The physics is **acceleration-based**, not constant-velocity. Stick
|
|
input sets an instant per-frame *acceleration*. That acceleration
|
|
adds to a persistent *velocity accumulator*, which adds to *position*
|
|
each frame. Release the stick and velocity persists -- the cab
|
|
drifts. Push opposite to decelerate. Gravity is a constant
|
|
acceleration always applied to Y.
|
|
|
|
### Variables (named for what they actually hold)
|
|
|
|
| Var | Meaning |
|
|
| ------------ | ------------------------------------------------------------- |
|
|
| `$7148/49` | Per-frame X acceleration (the "instant velocity" from input) |
|
|
| `$714A/4B` | Per-frame Y acceleration |
|
|
| `$714C/4D` | X velocity accumulator (16-bit signed) -- persists across frames |
|
|
| `$714E/4F` | Y velocity accumulator |
|
|
| `$7D91/92` | X-accel magnitude template (= 14 in level 1) |
|
|
| `$7D8F/90` | Y-accel magnitude template (= 14) |
|
|
| `$7D95/96` | X gravity (= 0 -- no horizontal drift) |
|
|
| `$7D93/94` | Y gravity (= 1 -- constant downward acceleration) |
|
|
| `$7D61/62` | Persistent X position, 16-bit fixed-point |
|
|
| `$7D64/65` | Persistent Y position |
|
|
| `$7D63` | 1-bit X fractional carry |
|
|
| `$7185` | Mirror of `$7D63` |
|
|
| `$7175` | Cached high byte of Xpos (= pixel column) |
|
|
| `$717D` | Cached high byte of Ypos (= pixel row) |
|
|
|
|
### Per-frame algorithm (`$6032`, full trace) -- VERIFIED via emulator
|
|
|
|
Verified by running `$6032` through the 6502 emulator with various
|
|
joystick / sprite-ptr states. See `stuff/spacetaxi/trace.py`.
|
|
|
|
```
|
|
1. Clear instant accel: $7148/49 = 0; $714A/4B = 0 [$6032-$603F]
|
|
|
|
2. Read input: $7169 = $DC00 EOR $FF [$6040-$6045]
|
|
|
|
3. Menu-mode test: if $07A6 == $7B: $7169 &= $10 (FIRE only); jump to vy [$6056-$6065]
|
|
else: fall through
|
|
|
|
4. Horizontal accel gate (this branch was missing from my earlier write-up):
|
|
if $7197 & 1 == 0: [$606F-$6076]
|
|
(a) $7148/49 = $7D91/92 (xAccel template, default +)
|
|
(b) if $7169 & $08 (RIGHT) : keep positive [$6088-$608A]
|
|
(c) else (LEFT) : two's-complement negate [$608C-$60A2]
|
|
if $7197 & 1 == 1: [$6190-$6198]
|
|
$7169 &= $13 (mask out L/R bits) -- horizontal accel SUPPRESSED this tick
|
|
|
|
5. Vertical accel: [$60A4-$60D3]
|
|
if $7169 & $03 (U/D):
|
|
$714A/4B = $7D8F/90 (yAccel template)
|
|
if UP bit set: negate
|
|
|
|
6. Integrate accel -> velocity: [$60D6-...]
|
|
$714C/4D += $7148/49 + $7D95/96 (X vel += X accel + X grav)
|
|
$714E/4F += $714A/4B + $7D93/94 (Y vel += Y accel + Y grav)
|
|
|
|
7. Integrate velocity -> position:
|
|
$7D61/62 += $714C/4D (Xpos += X vel)
|
|
$7D64/65 += $714E/4F (Ypos += Y vel)
|
|
|
|
8. Update caches:
|
|
$7175 = high byte of $7D61/62 (pixel col)
|
|
$717D = high byte of $7D64/65 (pixel row)
|
|
```
|
|
|
|
Verified single-tick results from emulator runs with level-A
|
|
templates (xAccel=$0E=14, yAccel=$0E=14, xGrav=0, yGrav=1) and a
|
|
zeroed velocity accumulator:
|
|
|
|
| Input | $7197 | ax | ay | vx after | vy after |
|
|
| ------ | ------ | --- | --- | -------- | -------- |
|
|
| none | $C0 | 0 | 0 | 0 | +1 |
|
|
| RIGHT | $C0 | +14 | 0 | +14 | +1 |
|
|
| LEFT | $C0 | -14 | 0 | -14 | +1 |
|
|
| UP | $C0 | 0 | -14 | 0 | -13 |
|
|
| DOWN | $C0 | 0 | +14 | 0 | +15 |
|
|
| RIGHT | $C1 | 0 | 0 | 0 | +1 | <- parity 1 suppresses X
|
|
| RIGHT+UP | $C0 | +14 | -14 | +14 | -13 |
|
|
|
|
**Asymmetric gating**: only X accel is gated by `$7197 & 1`. Y accel
|
|
fires regardless. So when the sprite-ptr flicker bit is 1, the cab
|
|
can still go up/down but not left/right.
|
|
|
|
**When does the parity bit flip during gameplay?** Hot-grep for
|
|
`EOR #$01 ... STA $7197`:
|
|
- `$6A93/$6A95` -- only runs in collision-phase 1 (during death)
|
|
- `$63FF/$6401` -- `fireButtonEdge`, fires on FIRE button rising edge
|
|
|
|
`$619B` (called every frame in gated dispatch) WRITES `$7197` but
|
|
PRESERVES the parity bit (`ORA $61` where `$61` holds the captured
|
|
LSB). So during normal hover-fly gameplay, `$7197` parity changes
|
|
only when the player presses FIRE.
|
|
|
|
This means: in stretches of gameplay where FIRE isn't pressed, the
|
|
parity bit is fixed -- so the cab either has horizontal accel every
|
|
frame (parity = 0) or never (parity = 1). Effective horizontal
|
|
acceleration rate depends on the live state of `$7197 & 1`. I have
|
|
not yet traced what sets the initial parity bit on level entry, so
|
|
the steady-state value during ordinary play is currently unknown.
|
|
|
|
The 16-bit add helper at `$4072` is the integration step, called
|
|
12 times across the routine (vx+grav, +dx_accum, +Xpos and the Y mirror).
|
|
Inputs at zero-page `$61-$64`, output back to `$61/62`.
|
|
|
|
The fractional carry at `$6124-$6132` adds an extra +1 to the X step
|
|
every other frame when the high byte's carry chain dictates --
|
|
gives sub-pixel granularity without floating point.
|
|
|
|
### Why the dump showed `dx_accum = 84`, `dy_accum = -66`
|
|
|
|
At dump capture time the cab was mid-flight, drifting right (vel +84
|
|
sub-pixel-units/frame) and rising (vel -66 sub-pixel-units/frame in
|
|
screen-coords where +Y is down). Confirms the velocities accumulate
|
|
and persist across frames.
|
|
|
|
### Templates and gravity per level
|
|
|
|
Per-level writers re-set `$7D8F-$7D96` at level init (`$5BD4`) so
|
|
different levels can have different gravity / handling. Level 1 uses
|
|
{X-accel 14, Y-accel 14, X-grav 0, Y-grav 1}. Different levels may
|
|
have stronger gravity, sluggish accel, etc.
|
|
|
|
The templates are saved on death (`$5A9E-$5ABC` copies `$7D8F-$7D96`
|
|
into a cache at `$5CE9-$5CF0`) and restored on respawn (`$5BD1-$5BFE`
|
|
copies the cache back). This is part of the trampoline disable/enable
|
|
flow described in "Trampoline patching" below.
|
|
|
|
### Physics is called from 3 places
|
|
|
|
`$6032` is a generic motion subroutine called from:
|
|
|
|
- `$5F45` -- main gameplay (gated by `$7164 == 0` = no collision dispatch)
|
|
- `$5C2C` -- death/fall animation loop (exits when `$717D == $D4` = row 212)
|
|
- `$5C91` -- takeoff animation loop (exits when `$717D < $87` = row 135)
|
|
|
|
Same motion math is reused; only the exit condition differs.
|
|
|
|
### Screen-edge behavior -- BOUNCE, not wrap (`$6AED`)
|
|
|
|
`$6AED` reflects X-velocity at the screen edges instead of wrapping
|
|
or stopping. Two branches:
|
|
|
|
```
|
|
$6AED if $7185 == 0 (low fractional, going left):
|
|
if vx-hi ($714D) < 0 AND col ($7175) < $17 (= 23):
|
|
negate ($714C/$714D) ; EOR #$FF, INC for two's complement
|
|
$6B17 else if $7185 != 0 (going right):
|
|
if vx-hi ($714D) >= 0 AND col ($7175) >= $41 (= 65):
|
|
negate ($714C/$714D)
|
|
```
|
|
|
|
So crossing column 23 going left, or column 65 going right, inverts
|
|
vx. The cab bounces off the screen edges like a bumper. JoeyLib port
|
|
currently clamps with vx=0 -- needs updating to match.
|
|
|
|
## Per-level data table (`$7D00-$7D60`)
|
|
|
|
Per-level data lives in this 96-byte region, written by the level
|
|
loader (the `$6283` bank-switch wrapper calls into `$9656` which
|
|
runs in all-RAM mode and reads level data from RAM beneath the
|
|
Kernal/I/O area). Layout derived from readers:
|
|
|
|
| Offset | Use |
|
|
| ------------ | ---------------------------------------------------- |
|
|
| `$7D09` | Special-pad sentinel: $6E2E branches when active |
|
|
| | pad index ($7150) matches this |
|
|
| `$7D0A,X*8` | Pad table base. Each slot is 8 bytes: |
|
|
| `,X+0` | pad data E (read by $645C / $67E3 setup) |
|
|
| `,X+1` | pad data F |
|
|
| `,X+2` | pad-X bound2 high |
|
|
| `,X+3` | pad-X bound2 low |
|
|
| `,X+4` | pad-X bound1 high |
|
|
| `,X+5` | pad-X bound1 low |
|
|
| `,X+6` | pad row (compared with $717D = taxi row) |
|
|
| `,X+7` | (reserved / decorative?) |
|
|
| `$7D5A` | Pad count (number of slots used in the table) |
|
|
| `$7D5B-$5D` | Taxi spawn X position: lo, hi, frac |
|
|
| `$7D5E-$5F` | Taxi spawn Y position: lo, hi |
|
|
| `$7D60` | Fuel max (or initial pad-cell count) |
|
|
|
|
### Pad detection (`$645C`)
|
|
|
|
Gated routine, called every frame from the post-tick chain. Only
|
|
runs when `$7150 == 0` (airborne) and the anim-parity bit is set
|
|
and Y velocity high byte is zero (slow descent). Then:
|
|
|
|
```
|
|
X = $7D5A (pad count); decrement X
|
|
loop:
|
|
if $717D (taxi row) != $7D0E,X*8 (pad row): try next pad
|
|
set $61/$62 = $7D62/$7D63 (taxi Xpos)
|
|
set $63/$64 = $7D0B,X*8 / $7D0A,X*8 (pad X bound)
|
|
JSR $6565 (16-bit compare); if Xpos < bound1, skip
|
|
set $63/$64 = $7D62/$7D63 again (taxi Xpos)
|
|
set $61/$62 = $7D0D,X*8 / $7D0C,X*8 (pad X bound2)
|
|
JSR $6565; if bound2 < Xpos, skip
|
|
$7150 = $7151 = X+1 ; mark on-pad
|
|
$71CD = 0
|
|
compare X+1 to $7D8E (active sprite idx); if equal, jump $64F8
|
|
; (continuation handles death-counter, music, etc.)
|
|
```
|
|
|
|
So pad landing is detected by **row match** + **column bounds
|
|
overlap**, indexed off the per-level pad table. The result is
|
|
stashed in `$7150` (1-based pad index, 0 = airborne).
|
|
|
|
## Game state sentinel
|
|
|
|
`$07A6` is the top-right cell of screen RAM. Used as a one-byte
|
|
state tag distinguishing menu mode (`$7B`) from gameplay. Many
|
|
input/handler sites branch on this value. Title/instruction/score
|
|
screens write `$7B` there; level start writes something else.
|
|
|
|
## Crash / pad / passenger systems (mostly traced)
|
|
|
|
The original game leans entirely on VIC-II hardware collision
|
|
registers, no per-cell screen-RAM walking:
|
|
|
|
- `$D01E` = sprite-to-sprite collision latch
|
|
- `$D01F` = sprite-to-background collision latch (reading either
|
|
also clears the latched bits)
|
|
|
|
Three call sites use them:
|
|
|
|
| Site | Role |
|
|
| ------- | ---------------------------------------------------- |
|
|
| `$6920` | clear-only (reads to reset latches, JSRs anim tick) |
|
|
| `$6946` | death/transition: `STA #$00 -> $D015` (sprites off), |
|
|
| | clear collision/menu/animation state |
|
|
| `$6966` | per-frame dispatch: latch both regs, then jump to |
|
|
| | `$6A72`/`$6B24`/... based on `$7164` phase |
|
|
|
|
The phase byte `$7164` is the dispatcher; `$7165/$7166` form a
|
|
frame-rate gate (count down each frame, reload when zero) so each
|
|
phase handler runs every N frames, not every frame.
|
|
|
|
### Collision dispatch trace (VERIFIED via emulator)
|
|
|
|
Verified by running `$6966` from `Tracer.from_dump('raw.bin')` with
|
|
hand-set `$D01E`/`$D01F` values and observing the resulting `$7164`
|
|
phase and `$7197` cel transitions.
|
|
|
|
| `$D01E` (ss) | `$D01F` (bg) | Phase after | `$7197` | `$721D` | Outcome |
|
|
| ------------ | ------------ | ----------- | ------- | ------- | ----------------- |
|
|
| $00 | $00 | 0 | $C1 | - | no change |
|
|
| $00 | $01 | **1** | **$CC** | $01 | DEATH |
|
|
| $09 | $00 | **1** | **$CC** | $01 | DEATH (passenger 3 + sprite 0 collision) |
|
|
| $09 | $01 | **1** | **$CC** | $01 | DEATH (bg wins) |
|
|
| $08 | $00 | 0 | $C1 | - | no change (sprite-sprite without sprite 0 bit) |
|
|
|
|
**The $7D75 trampoline is STATIC** in both `raw.bin` (under-ROM
|
|
capture) and `mem0000-level1.bin`. The bytes are `$4C $9A $7D`
|
|
(JMP `$7D9A`) where `$7D9A` is `$60` (RTS). So the trampoline
|
|
ALWAYS returns A unchanged. Grep across the entire disassembly
|
|
finds NO writers to `$7D76` or `$7D77`. The "trampoline gets patched
|
|
to allow pickup" theory I had earlier (in an earlier draft of this
|
|
doc) was wrong -- the operand is fixed.
|
|
|
|
Demonstrated mechanism via emulator: manually plant `LDA #$00; RTS`
|
|
at `$7DA0` and patch `$7D76/$7D77` to point there. After:
|
|
|
|
| Trampoline target | After taxi+passenger collision |
|
|
| ------------------ | ----------------------------------- |
|
|
| `$7D9A` (RTS) | phase=$01, sprite $C1->$CC, DEATH |
|
|
| `$7DA0` (LDA #$00) | phase=$00, sprite $C1, no death |
|
|
|
|
So the mechanism EXISTS but isn't used in any state we've captured.
|
|
The RTS-without-death path at `$69B3` requires `$71CA` bit 1
|
|
(sprite 1 = flame) ALSO set, which is a multi-sprite collision
|
|
involving cab + flame + passenger. Possibly that's how pickup is
|
|
detected -- the C64 game arranges for the flame sprite to be
|
|
involved in a 3-way collision when boarding. Not yet verified.
|
|
|
|
For the JoeyLib port: my explicit pickup logic ("if cab is landed
|
|
on the passenger's pad, board them") is a clean simplification.
|
|
The C64's mechanism via 3-way sprite collision is too entangled
|
|
with hardware-specific behavior to reproduce directly.
|
|
|
|
### Phase 1 handler (`$6A72`) -- fall + thrust SFX
|
|
|
|
This handles in-flight gameplay. Each tick it:
|
|
|
|
1. Reads `$721D` -- if high bit set (BMI taken), jumps to the
|
|
phase-advance / clamp branch (player is dying).
|
|
2. Otherwise decrements `$721B` (thrust freq sweep accumulator),
|
|
shifts it right one bit, writes both copies to `$D400/$D401`
|
|
(voice 1 frequency lo/hi). This is the **thrust hum** -- voice
|
|
1 freq decrements over the course of a thrust burst.
|
|
3. Toggles `$7197` bit 0 -- frame-parity flag for visual sync.
|
|
4. Integrates `$714C/$714D` (per-frame dx) into `$714E/$714F`
|
|
(sub-pixel row accumulator), +40 per frame.
|
|
5. Adds the integrated row delta into `$717D` (taxi pixel row).
|
|
6. If `$717D >= $DA` (218), clamps it and **INCs `$7164`** --
|
|
advances to phase 2 (the floor/pad has been reached).
|
|
7. Falls through to a screen-edge wrap routine at `$6AED` that
|
|
inverts `$714C` when the taxi crosses column 23 (`$17`).
|
|
|
|
### Phase 2 handler (`$6B24`) -- death sprite-cel walk
|
|
|
|
After phase 1 clamps and INCs `$7164` to 2, this handler runs:
|
|
|
|
```
|
|
DEC $7165, BEQ continue, else RTS ; frame-rate gate
|
|
INC $7166 ; slow the rate over time
|
|
$7166 -> $7165 ; reload gate
|
|
INC $7197 ; advance sprite-0 cel ptr
|
|
LDA $7197; CMP #$D1; BEQ advance ; reached final death cel?
|
|
RTS ; otherwise stay in phase 2
|
|
|
|
advance:
|
|
$718E = 0
|
|
INC $7164 ; -> phase 3
|
|
$7165 = $46 (70 frames)
|
|
$68/$69 = $6DDF (death art pointer)
|
|
JSR $42E9 (load death SFX/art)
|
|
```
|
|
|
|
So phase 2 walks the sprite-0 pointer from `$CC` through `$D0` (death
|
|
animation cels at `$3300, $3340, ... $3400`), one cel per N frames
|
|
where N grows over time (slowing-down "fall apart" effect). When it
|
|
hits `$D1`, transitions to phase 3.
|
|
|
|
### Phase 3 handler (`$6B4C`) -- pause, lives--, respawn or game-over
|
|
|
|
```
|
|
DEC $7165, BEQ continue, else RTS ; 70-frame pause
|
|
LDA $721C
|
|
BEQ $6B64 ; not in menu state, continue
|
|
PLA PLA ; drop our return + caller's return
|
|
LDA $5E9D
|
|
BNE $4955 (exitToTitle)
|
|
JMP $5EC4 (gameInitContinue) ; restart gameplay
|
|
```
|
|
|
|
Otherwise (gameplay state):
|
|
```
|
|
LDA $7163; CMP #$05; BNE $6BBA ; death-stage gate
|
|
JSR $43A5 (hudInit)
|
|
LDA $715C (lives); BEQ $6BA1 ; out of lives -> game-over
|
|
LDX $716D (active dying slot)
|
|
$7152,X = 0 ; blank slot
|
|
DEC $715C (lives--)
|
|
LDA $7D8E; CMP #$0B ; was it the "main" slot?
|
|
BNE $6B96 ; not -> continue alt branch
|
|
$0412/$0413/$0414/$0415 = $67 ; rewrite HUD chars
|
|
JMP $6BA1 (game-over path)
|
|
```
|
|
|
|
The respawn path at `$6BBA` decrements `$71CF,X` (per-passenger
|
|
counter), if zero jumps to `$6BD0` (post-death-transition); else
|
|
patches HUD char and JMPs `$5F27` (mid-prelude re-entry).
|
|
|
|
`$6BD0` (post-death-transition): JSR `$704E`, JSR `$5FBB`,
|
|
INC `$71CE` (fare counter), compare to `$7213`; if all fares
|
|
delivered, JMP `$602F` (level complete); else JMP `$5F02`
|
|
(main loop re-entry).
|
|
|
|
So the death pipeline is:
|
|
- Sprite-bg hit -> $6A30 starts phase 1
|
|
- Phase 1 -> cab continues physics, BMI on $721D drives to phase 2
|
|
- Phase 2 -> animates sprite-0 cel forward $CC..$D1 (death anim)
|
|
- Phase 3 -> 70-frame pause, then DEC $715C, blank slot, transition
|
|
|
|
## Main game loop (`$5F18-$5FB8`)
|
|
|
|
Falls through from gameInit (`$5EC1`) on level entry via
|
|
`$6FDC: JMP $5F18`. Tight loop, **no per-frame wait** in the JMP
|
|
back -- music IRQ ticks asynchronously, but `$5F94`/`$5FA3` calls
|
|
to `$404B` (VBL wait) provide explicit raster sync mid-loop.
|
|
|
|
```
|
|
$5F18 prelude 8 JSRs (level-entry one-time setup):
|
|
$61FB advanceFareSlot
|
|
$4248 spriteShadowInit
|
|
$6946 spritesOffReset
|
|
$7D66 stateVec0 (RTS stub on level1)
|
|
$6F18 takeoffSetup
|
|
$6888 taxiSpawnInit
|
|
$7D69 stateVec1 (RTS stub on level1)
|
|
$6906 framePresent
|
|
|
|
$5F40 loop top Gated dispatch: 9 JSRs guarded by
|
|
LDA $7164 (collisionPhase); BNE skip.
|
|
Skipped during death animation:
|
|
$6032 physicsTick
|
|
$63DD fireButtonEdge
|
|
$6D6A flameSpriteUpdate
|
|
$645C padDetect
|
|
$619B taxiSpriteCelSelect
|
|
$6BE7 levelEndCheck
|
|
$6DFF padLandingBob
|
|
$6E23 padPaymentAnim
|
|
$61BD passengerEventDraw
|
|
|
|
$5F88 post-work Unconditional JSRs (animation, sprite
|
|
updates, scoring, etc.):
|
|
$67E3 postTickStateGate
|
|
$65C1 deathStageDispatch
|
|
$7D6C stateVec2
|
|
$4253 spriteShadowMarshal
|
|
$404B vblWait <- explicit VBL
|
|
$4293 spriteShadowFlush <- now in vblank
|
|
$63D0 bitScrollRight
|
|
$7D6F stateVec3
|
|
$6419 fuelBarHud
|
|
$404B vblWait <- second VBL sync
|
|
$6966 collisionDispatch
|
|
$43E5 hudDraw
|
|
$4320 sfxEnvelopeTick
|
|
$4FCB runStopWatcher
|
|
$70B1 passengerArrTick
|
|
$4BF8 (dead -- single-shot DDR setup, fall-through)
|
|
|
|
$5FB8 JMP $5F40 loop close -- repeats dispatch + post-work.
|
|
Prelude is one-shot on entry.
|
|
```
|
|
|
|
The two `$404B` VBL waits straddle the sprite flush so screen tear
|
|
doesn't happen during the shadow-to-hardware copy.
|
|
|
|
The single entry to `$5F18` is `$6FDC JMP $5F18`. Three sites in
|
|
the init chain jump to `$48AD` instead -- that's the **menu / title
|
|
state** alternate path.
|
|
|
|
### State-vector dispatch (`$7D66-$7D75`)
|
|
|
|
Six adjacent 3-byte slots, each holding `JMP abs`. The main loop
|
|
calls into them via `JSR`, so the slot's `JMP abs` operand acts as
|
|
a function pointer.
|
|
|
|
**Trampoline patching mechanism**: state transitions don't patch
|
|
the JMP operand bytes (`$7D67/$7D68`, etc.) -- those are static.
|
|
What gets patched is the OPCODE byte (`$7D66`, `$7D69`, etc.):
|
|
|
|
- Writing `$60` (RTS) to the opcode byte = trampoline disabled,
|
|
whole slot is a no-op `JSR <RTS>`.
|
|
- Writing `$4C` (JMP abs) = trampoline enabled, jumps to whatever
|
|
pre-baked operand follows.
|
|
|
|
`$5AC1-$5AD0` is the **trampolines-off** patcher (writes #$60 to
|
|
all six opcode bytes), `$5C03-$5C12` is **trampolines-on** (#$4C).
|
|
These bookend the death-and-respawn sequence: shut everything off,
|
|
play the death anim with most main-loop work neutered, then
|
|
re-enable for respawn.
|
|
|
|
The JMP targets in level 1's gameplay snapshot (`mem0000-level1.bin`):
|
|
|
|
| Slot | Called from main loop | Target | Role |
|
|
| ------ | --------------------- | --------------- | --------------- |
|
|
| `$7D66`| prelude ($5F21) | `$7D9C` (RTS) | post-prelude 0 |
|
|
| `$7D69`| prelude ($5F2A) | `$7D9B` (RTS) | post-prelude 1 |
|
|
| `$7D6C`| post-work ($5F8E) | `$7D9D` (RTS) | per-tick 2 |
|
|
| `$7D6F`| post-work ($5F9D) | `$7D98` (RTS) | per-tick 3 |
|
|
| `$7D72`| `$6283` decompress | `$7D99` (RTS) | level-init hook |
|
|
| `$7D75`| hit dispatch ($6A03) | `$7D9A` (RTS) | passenger-hit verdict |
|
|
|
|
On the title screen (`mem0000.bin`/`mem0000-title.bin`), some of
|
|
the operands point at real routines (`$7E5A` for slot 2,
|
|
`$7E57` for slot 5). Those operands are loaded as static data
|
|
during the title-screen boot, not patched by code we've found.
|
|
|
|
### The `$7D75` hit-dispatch trampoline (resolved)
|
|
|
|
Called from `$6A03` with `A=#$01`:
|
|
|
|
```
|
|
$6A01 LDA #$01
|
|
$6A03 JSR $7D75 ; trampoline -- may modify A
|
|
$6A06 STA $721D ; player-state byte
|
|
$6A09 LDA $71CB
|
|
$6A0C AND #$01 ; taxi-vs-background?
|
|
$6A0E BNE $6A1E ; -> death start
|
|
$6A10 LDA $5569
|
|
$6A13 BEQ $6A16
|
|
$6A15 RTS ; menu-mode-ish gate
|
|
$6A16 LDA $721D
|
|
$6A19 CMP #$00
|
|
$6A1B BNE $6A1E ; -> death start if trampoline said die
|
|
$6A1D RTS
|
|
```
|
|
|
|
So the trampoline's role is to **decide whether a passenger contact
|
|
should kill the cab**. The default `$7D9A` target (`RTS`) leaves
|
|
A=1, so $721D=1, so the BNE at $6A1B IS taken -- passenger contact
|
|
without further state would crash the cab. That's actually consistent
|
|
with the original behavior: random passenger collisions DO kill you;
|
|
you have to be on a pad in the right state for them to board.
|
|
|
|
When the per-level setup wires a different target (e.g. `$7E57`,
|
|
`LDA #$00; RTS`), A=0 after the trampoline, $721D=0, BNE not taken,
|
|
passenger contact is benign. This must be triggered during the
|
|
pickup window (passenger on top of cab while landed on right pad).
|
|
|
|
JoeyLib port doesn't need this trampoline scheme -- our pickup logic
|
|
lives explicitly in `stPassenger.c`.
|
|
|
|
### `$7D72` level-init hook
|
|
|
|
Called only via the `$6283` bank-switch wrapper. Probably loaded
|
|
with a per-level "post-load setup" target by whatever generates the
|
|
per-level table. On gameplay-state dumps it's `RTS`.
|
|
|
|
## Title / menu state (`$48AD`)
|
|
|
|
Entry from `gameInit` (`$5EC1`) when `$5E9D != 0` (post-mortem) or
|
|
`$721C != 0` (menu mode requested).
|
|
|
|
```
|
|
$48AD $7213 = 1 ; one fare needed
|
|
$721C = 1 ; menu mode
|
|
$7214 = 0
|
|
$7171/$7172 = 1 ; menu timers
|
|
$3F/$40 = $0900 ; script buffer
|
|
$5569 = 0 ; clear paused flag
|
|
$595C = $20
|
|
$5975 = $2A
|
|
if $5E9D:
|
|
$0900/$0901 = $00 $FF
|
|
JMP $5F02 (mainEntry)
|
|
else:
|
|
$3F = 2
|
|
JMP $5F02
|
|
```
|
|
|
|
After the JMP to `$5F02`, the main loop runs with `$721C=1`, which
|
|
causes the post-tick chain to behave differently (menu-only paths
|
|
in $4FCB, $6BE7). The actual title-screen DRAWING happens via
|
|
`$4525` (title sprite setup) and `$4861` (logo color cycle).
|
|
|
|
`$4525` puts all 7 sprites at the same screen corner ($AA, $E4 / $8C),
|
|
sets their ptr to `$DA` ($3680), color to 7, then triggers song 8
|
|
via `$CB02`. This is the **idle title state**.
|
|
|
|
`$4955` is the reverse: gate-off voice 1+2, noise off, 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 `$CC8D` for **note-start** (now traced below)
|
|
|
|
### Note start (`$CC8D`) -- full sequence
|
|
|
|
```
|
|
LDA $CB81,X ; track ptr hi
|
|
BEQ gate_off ; track inactive
|
|
STA $FD; LDA $CB80,X; STA $FC ; zp $FC/$FD = ptr
|
|
LDY = 0
|
|
LDA ($FC),Y ; read next byte
|
|
BEQ gate_off ; $00 = end of track
|
|
CMP #$80
|
|
BNE check_note ; $80 = end of song -> clear ptr + gate off
|
|
gate_off: ; $D404,X &= $FE
|
|
JMP advance_ptr
|
|
|
|
check_note:
|
|
AND #$7F ; mask high bit (effect flag)
|
|
CMP #$61 ; note count = 97
|
|
BCC normal_note
|
|
JMP $CD56 ; effect command (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 (`$4861` is called from
|
|
the main loop, not the IRQ)
|
|
|
|
All of those run between IRQ ticks at main-loop speed. The IRQ is
|
|
strictly the **music heartbeat**.
|
|
|
|
## Sound (SID)
|
|
|
|
**Gameplay is silent except for SFX.** Music tracks only load via
|
|
`JSR $CB02` (musicInit), and that's called from exactly 5 sites,
|
|
none of them in gameplay code paths:
|
|
|
|
| Site | Song | Role |
|
|
| ------- | ---- | ----------------------------------------------- |
|
|
| `$459C` | 8 | title sprite setup (start title music) |
|
|
| `$46BC` | 8 | title demo loop restart |
|
|
| `$477C` | 25 | title init via `titleEnterContinue` ($4741) |
|
|
| `$4C29` | 7 | score-screen draw (between-level jingle) |
|
|
| `$4EB6` | 6 | second score-screen variant |
|
|
|
|
The 8 gameplay-loop preludes at `$5F18` (`$61FB`, `$4248`, `$6946`,
|
|
`$6F18`, `$6888`, `$6906`, and the two state-vec trampolines) do
|
|
not load any songs. The IRQ music engine (`$CF52` → `$CC6C` per
|
|
voice) still ticks every frame during gameplay, but all three
|
|
voice track pointers (`$CB80/$CB81,X`) are zero so the engine
|
|
walks-and-does-nothing. Audio during gameplay is purely event-
|
|
driven SFX (thrust freq sweep, crash noise, etc).
|
|
|
|
There is no per-level music. The port's `StLevelT.musicId` field
|
|
is dormant scaffolding; loading songs at level transitions is
|
|
non-authentic and was removed.
|
|
|
|
Mapping of voice usage confirmed by site-by-site survey:
|
|
|
|
| Voice | Register | Role |
|
|
| ------------- | ------------- | --------------------------- |
|
|
| 1 (triangle) | `$D400-$D406` | jingle music (non-gameplay) + thrust freq sweep + crash scream |
|
|
| 2 | `$D407-$D40D` | jingle melodic support voice (non-gameplay) |
|
|
| 3 (noise) | `$D40E-$D412` | crash burst + jet-engine continuous noise |
|
|
|
|
Patterns observed:
|
|
|
|
- **Thrust hum**: phase-1 handler decrements `$721B`, writes the
|
|
shifted value to `$D400/$D401`. Continuous while `$721B > 0`.
|
|
- **Jet noise**: `$6D6A` writes `$81` (noise + gate ON) to `$D412`
|
|
while direction input is held; `$80` (gate off) when released.
|
|
- **Crash noise**: `LDA #$81 STA $D412` (noise waveform + gate on)
|
|
followed by `LDA #$80 STA $D412` (gate off) after a short
|
|
duration -- this is the noise-burst envelope.
|
|
- **Note release**: per-voice envelope tick at `$4320` clears the
|
|
gate bit of `$D404` / `$D40B` when timers `$7216` / `$7217` hit
|
|
zero. The timer is loaded from `$7218` / `$7219` (the SFX program
|
|
byte that holds the wave|gate mask).
|
|
- **SFX load**: `$42E9` reads a **9-byte program** from `($68/$69)`
|
|
(VERIFIED via emulator):
|
|
|
|
```
|
|
byte 0: SID freq lo -> $D400+vi*7
|
|
byte 1: SID freq hi -> $D401+vi*7
|
|
byte 2: SID pulse lo -> $D402+vi*7
|
|
byte 3: SID pulse hi -> $D403+vi*7
|
|
byte 4: SID ctrl byte -> $D404+vi*7 AND stored to $7218+vi
|
|
byte 5: SID AD -> $D405+vi*7
|
|
byte 6: SID SR -> $D406+vi*7
|
|
byte 7: release-timer -> $7216+vi
|
|
byte 8: voice index (0,1,2 = SID voice 1,2,3) -- read FIRST
|
|
```
|
|
|
|
Routine reads byte 8 first to determine voice, gates off the
|
|
selected voice ($D404+X = 0), then writes the 7 SID register
|
|
values. Earlier MECHANICS draft said "voice index then 7 bytes"
|
|
(wrong order, wrong byte count) -- corrected after trace.
|
|
|
|
## Sprite system (table-driven, IRQ flushed)
|
|
|
|
Game code never writes the VIC sprite registers directly. It only
|
|
updates **shadow tables** in main memory; a per-frame copy routine
|
|
at `$4293` (called via `$5F97`) flushes them to hardware:
|
|
|
|
| Shadow | Hardware | Width | Purpose |
|
|
| -------------- | ---------------- | ------- | ---------------------- |
|
|
| `$71A7-$71B6` | `$D000-$D00F` | 16 byt. | Sprite X/Y positions |
|
|
| `$7197-$719E` | `$07F8-$07FF` | 8 byt. | Sprite-data pointers |
|
|
| `$719F-$71A6` | `$D027-$D02E` | 8 byt. | Sprite colors |
|
|
| `$718D` | `$D010` | 1 byt. | Sprite X MSB mask |
|
|
| `$7196` | `$D015` | 1 byt. | Sprite enable mask |
|
|
|
|
### Position storage and marshaling
|
|
|
|
Per-sprite positions are kept in **two parallel 8-byte tables**:
|
|
|
|
- `$7175-$717C`: per-sprite pixel column (high byte of position)
|
|
- `$717D-$7184`: per-sprite pixel row (Y, simple byte)
|
|
|
|
`$4253` (called every frame from `$5F91`) is the **marshal**:
|
|
|
|
1. Packs the boolean array `$718E-$7195` (which holds per-sprite
|
|
"feet hidden" flags) into byte at `$7196` via 8x ROL through
|
|
carry. Same for `$7185-$718C` packed into the X-MSB shadow.
|
|
2. Bulk copies the two pos tables into the interleaved hardware
|
|
shadow `$71A7-$71B6` (X,Y,X,Y,... order matches the hardware
|
|
register layout).
|
|
|
|
The flush at `$4293` is then a single tight loop: `LDA shadow,X;
|
|
STA reg,X; DEX; BPL`. So the IRQ does no sprite work directly --
|
|
the per-frame routines `$4253` then `$4293` are called from the
|
|
main loop, bracketed by `$404B` VBL waits to avoid tearing.
|
|
|
|
## Taxi animation (confirmed)
|
|
|
|
The taxi has **two base cels** with a per-frame flicker variant,
|
|
giving 4 distinct cel ptr values:
|
|
|
|
- Base $C0 / $C1: cab (state A)
|
|
- Base $DC / $DD: cab (state B, "flame-on")
|
|
|
|
The cel-select logic in `$619B`:
|
|
|
|
```
|
|
ptr_base = (input has bit-2) ? $DC : ($C0 if bit-3 else <keep>)
|
|
parity = $7197 & 0x01 ; flickers each frame in $6A95
|
|
ptr = ptr_base | parity
|
|
```
|
|
|
|
Each phase-handler tick (`$6A95`), the taxi pointer shadow `$7197`
|
|
gets `EOR #$01`, alternating the parity bit. JoeyLib port has
|
|
`ST_TAXI_CEL_COUNT = 2`; should probably be 4 to fully replicate.
|
|
|
|
The 9 writers of `$7197` (sprite-0 ptr shadow) reveal state-
|
|
dependent base cels:
|
|
|
|
| Writer site | Value | Likely state |
|
|
| ----------- | ------------ | ------------------------- |
|
|
| `$4A56`, `$5B82`, `$6894` | `#$C0` -> `$3000` | Menu / title cab |
|
|
| `$5CA1` | `#$E2` -> `$3880` | Different state cel |
|
|
| `$6A2D` | `#$CC` -> `$3300` | Phase-2 (death anim start) |
|
|
| `$6A95` | `EOR #$01` | In-flight flicker |
|
|
| `$6B33` | `INC` | Phase-2 cel walk |
|
|
| `$619B`, `$6401`, `$65B2` | computed (in handler) | Various dispatch |
|
|
|
|
## Engine flame -- sprite 2 (VERIFIED CORRECTION)
|
|
|
|
CORRECTION: the flame is **sprite 2** (not sprite 1 as I had earlier).
|
|
Verified by checking `$6D6A`: it writes to `$7177` (sprite-2 X) and
|
|
`$7199` (sprite-2 ptr shadow). Sprite 1 (`$7176` X, `$7198` ptr) is
|
|
the **active passenger sprite** set by `$660B` / `$6650` etc.
|
|
|
|
Sprite role assignment in Space Taxi:
|
|
- sprite 0 (taxi): `$7175` X, `$7197` ptr
|
|
- sprite 1 (active passenger): `$7176` X, `$7198` ptr
|
|
- sprite 2 (flame): `$7177` X, `$7199` ptr
|
|
- sprites 3-7: additional passengers / level decoration
|
|
|
|
`$6D6A` (called every frame from the gated dispatch) positions
|
|
sprite 2 two pixels left of the taxi and one row above (in
|
|
Y-frac terms), pointed at a direction-indexed cel from table
|
|
`$6DB0,X` (X = $716A direction mask). Sprite 2 is hidden by
|
|
zeroing `$7190` when no input is held.
|
|
|
|
So the engine flame is:
|
|
- A separate sprite (sprite 1)
|
|
- Positioned (taxi_x - 2, taxi_y) when active
|
|
- Cel ptr depends on direction held
|
|
- Hidden when no input
|
|
|
|
JoeyLib port currently overlays flame as a tile/sprite under the
|
|
cab; needs to be reworked to match the C64's per-frame
|
|
direction-cycling 8-entry cel table at $6DB0.
|
|
|
|
## Title screen layout
|
|
|
|
The title dump's screen RAM (taken with the title displayed)
|
|
shows borders at rows 0, 12, 24 (40 non-space chars each) and
|
|
content in rows 1-11 (logo + credits) and rows 13-23 (instruction
|
|
text / animated demo area). Our extractor preserves this layout
|
|
verbatim, so the port renders the same character placement once
|
|
the tile bank for `title.txt` is loaded.
|
|
|
|
### Logo color cycle (`$4861-$489B`)
|
|
|
|
Routine at `$4861` advances a counter at `$48A4` mod 8, looks up
|
|
a color from the 8-entry table at `$489C-$48A3`, and paints that
|
|
color into 11 rows x 37 columns of color RAM -- the logo area.
|
|
|
|
The 8-color cycle table (C64 codes):
|
|
|
|
| Slot | Value | Color |
|
|
| ---- | ----- | ----------- |
|
|
| 0 | `$02` | red |
|
|
| 1 | `$08` | orange |
|
|
| 2 | `$07` | yellow |
|
|
| 3 | `$05` | green |
|
|
| 4 | `$06` | blue |
|
|
| 5 | `$0E` | light blue |
|
|
| 6 | `$03` | cyan |
|
|
| 7 | `$04` | purple |
|
|
|
|
Inner loop at `$4872-$4896` writes the color to `$D828+X`,
|
|
`$D850+X`, `$D878+X`, ..., `$D9B8+X` -- 11 row offsets, each 40
|
|
bytes apart, covering screen rows 1-10 (logo area). X goes 1..37.
|
|
Then a follow-up `JSR $49F8` writes A to `$719F+X` for X=7..3 --
|
|
**bulk-sets sprites 2..7 color to the same logo color** so the
|
|
title sprites pulse in sync with the logo.
|
|
|
|
## Bank switching for under-Kernal data (`$6283`)
|
|
|
|
```
|
|
PHP / SEI / TAX
|
|
LDA $01 ; save current memory config
|
|
STA $6293 ; stash via self-mod (operand of LDA #$37 below)
|
|
LDA #$38 ; bit0=0 (BASIC out), bit1=0 (Kernal out), bit2=0 (Char ROM @ D000)
|
|
STA $01 ; -> all-RAM mode
|
|
JSR $9656 ; data-load routine, can now read $A000-FFFF as RAM
|
|
LDA #$37 ; restore (self-mod operand was the saved $01)
|
|
STA $01
|
|
PLP / RTS
|
|
```
|
|
|
|
So `$6283` is the **bank-switch wrapper** for accessing per-level
|
|
data stored in RAM beneath the BASIC/Kernal/I/O ROMs. NOT a
|
|
decompressor (earlier MECHANICS draft was wrong). The actual
|
|
data-load happens in `$9656`; everything in $8000-$FFFF is open
|
|
RAM during the call.
|
|
|
|
This is called from `$5B34` (with A = song index, level-end death
|
|
tune setup), `$621F` (round-end reset), and `$5F` segment of init.
|
|
|
|
## Mapping to JoeyLib port
|
|
|
|
| C64 var/route | JoeyLib equivalent |
|
|
| -------------------- | ----------------------------------------------- |
|
|
| `$DC00` joystick | `jlJoystickX/Y` + `jlJoyDown` polling |
|
|
| `$7169` input mask | `applyInput()` produces `thrustDx/Dy/thrusting` |
|
|
| `$7148-4B` accel | `StTaxiT.ax, .ay` (currently inlined locals) |
|
|
| `$714C-4F` velocity | `StTaxiT.vx, .vy` |
|
|
| `$7D8F-92` templates | hard-coded constants in `stEngine.c` |
|
|
| `$7D61-63` position | `StTaxiT.x, .y` (32-bit subpixel) |
|
|
| `$07A6` state sentinel | `StGameT.state` enum |
|
|
| `$7164` collision phase | `StTaxiT.state` (airborne/landing/dying/etc) |
|
|
| `$7163` death stage | derived from above + frame counter |
|
|
| `$7150` active pad | `StTaxiT.onPad` |
|
|
| `$7D75` hit trampoline | explicit pickup check in `stPassenger.c` |
|
|
| `$6AED` edge bounce | `clampToField()` -- needs to bounce, not stop |
|
|
| `$4253` shadow marshal | implicit in `stRender.c` per-sprite draws |
|
|
| `$4293` shadow flush | jlStagePresent |
|
|
| `$404B` VBL wait | jlStagePresent (sync-on-present) |
|
|
| `$42E9` SFX load | `stAudioSfx*()` per-event functions |
|
|
| `$4320` SFX envelope | jlAudioFrameTick / per-platform mixer |
|
|
| `$6D6A` flame sprite | needs sprite-1 overlay, direction-indexed cel |
|
|
|
|
## Drop-off / death state machine (`$65C1` dispatch, all stages traced)
|
|
|
|
The dispatcher at `$65C1` is a 10-way `JMP table` on `$7163`. Despite
|
|
"death" in earlier notes, this is actually the **fare-success +
|
|
death + game-over** state machine. Same machine handles both
|
|
outcomes; the branch on `$7164` (collision phase) inside stage 1
|
|
decides.
|
|
|
|
| Stage | Addr | Role |
|
|
| ----- | ------ | --------------------------------------------------- |
|
|
| 0 | $660B | Idle RNG ticker between transitions |
|
|
| 1 | $665F | Branch: success-text OR death-finalize |
|
|
| 2 | $66B7 | Sprite-1 cel cycling animation |
|
|
| 3 | $66DA | (entry via indirect jump; see "Self-mod" below) |
|
|
| 4 | $66DD | Cel ptr walk up to $CC |
|
|
| 5 | $6739 | Pad-lighting alt color, clear death-in-progress |
|
|
| 6 | $6742 | Score update: BCD add to HUD |
|
|
| 7 | $67A3 | -> JMP $6CE8 (HUD finalization) |
|
|
| 8 | $67A6 | Sprite-1 ptr advance to $CC |
|
|
| 9 | $67C6 | Final cleanup, draw game-over screen text |
|
|
|
|
### Stage 0 (`$660B`) -- idle RNG ticker
|
|
|
|
Each frame:
|
|
- LDA #$64 (100); JSR `$4080` (random 0..A-1)
|
|
- if result >= 3, RTS (97% chance per frame to do nothing)
|
|
- DEC `$71CC` (100-tick countdown); if not zero, RTS
|
|
- $7164 == 0 (gate)
|
|
- $71CC := $64 (reload)
|
|
- JSR `$6537` -- read pad-spawn table, prep a target pad
|
|
- Save pad data into `$7186/$7176/$717E` (hover position)
|
|
- `$718F = $7163 = 1` -- advance to stage 1
|
|
- Reload `$715D = $715E`
|
|
|
|
So stage 0 is a **probabilistic delay**: roughly every (100*100)/3
|
|
frames (~83 seconds at 60Hz) it ticks a passenger arrival, picks a
|
|
pad, and transitions to stage 1.
|
|
|
|
### Stage 1 (`$665F`) -- success-text OR death-finalize
|
|
|
|
```
|
|
DEC $715D, gate
|
|
$715D = $715E (reload)
|
|
DEC $7198 (sprite-1 ptr) -- animates down toward $C7
|
|
LDA $7198; CMP #$C7; BEQ continue, else RTS
|
|
LDA $7164 ; collision phase
|
|
BEQ success ; phase 0 -> success path
|
|
death:
|
|
$7163 = 8 ; jump to stage 8
|
|
$7198 = $C7 ; reset cel
|
|
$7152,$7D8E = 0 ; blank dying slot
|
|
$7D8E = 0 ; clear active idx
|
|
DEC $715C (lives--) ; lose a life
|
|
RTS
|
|
success:
|
|
INC $7163 (-> stage 2)
|
|
Draw text at $6C45 via $41C2 (probably "FARE!" or "PASSENGER!")
|
|
JSR $6C7F (scoreboard refresh)
|
|
JSR $43D1 (HUD redraw)
|
|
RTS
|
|
```
|
|
|
|
So the cab gets a chance to recover during stage 1; if collision
|
|
phase is non-zero (we crashed), death finalizes; if zero, success
|
|
text draws and stages 2-7 run the scoring animation.
|
|
|
|
### Stage 2 (`$66B7`) -- sprite-1 cel cycling (sparkle)
|
|
|
|
```
|
|
DEC $715D, gate
|
|
$715D = $715E
|
|
INC $716E mod 4 -> X
|
|
$7198 = $66D6,X ; lookup from 4-entry table
|
|
RTS
|
|
```
|
|
|
|
Table at `$66D6`: `C6 C7 D9 C7` -- cycles sprite-1 ptr through
|
|
4 cels for a sparkle/flash effect.
|
|
|
|
### Stage 3 (`$66DA`) -- self-modifying indirect
|
|
|
|
Stage 3 entry is `INX; JMP ($5DCE)`. The indirect vector at `$5DCE`
|
|
is loaded per-stage to select a sub-handler. This is a runtime
|
|
state-machine override -- whatever the level's "success" sequence
|
|
needs gets pointed at via `$5DCE`. Both writers of $5DCE and the
|
|
exact sub-handlers depend on per-level state we haven't fully
|
|
probed. In level 1, $5DCE points back into the success-text drawer.
|
|
|
|
### Stage 4 (`$66DD`) -- "FARE COLLECTED" art
|
|
|
|
```
|
|
DEC $715D, gate
|
|
$715D = $715E
|
|
INC $7198 ; walk sprite-1 ptr up
|
|
CMP #$CC; BEQ continue, else RTS
|
|
$718F = 0 ; clear pad hover flag
|
|
INC $7163 (-> stage 5)
|
|
$716D = $7150 ; save active pad
|
|
JSR $6537 ; pad-table read
|
|
LDA $7D8E; CMP #$0B
|
|
BNE -> draw the next-fare art
|
|
... draw text at $6C1E via $41C2, A=1
|
|
... patch HUD chars at $07D2/$6C73/$6C7B with ($7D8E + $30)
|
|
JSR $6C7F (scoreboard)
|
|
RTS
|
|
```
|
|
|
|
So stage 4 walks the cel pointer up to $CC, then triggers the
|
|
"FARE!" banner draw + scoreboard update.
|
|
|
|
### Stage 5 (`$6739`) -- pad color alt
|
|
|
|
Trivial: `$7167 = 0`; JSR `$6877` (alt color); RTS.
|
|
|
|
### Stage 6 (`$6742`) -- score update via BCD add
|
|
|
|
```
|
|
DEC $715D, gate
|
|
$715D = $715E
|
|
DEC $7198 ; back down to $C7
|
|
CMP #$C7; BEQ continue, else RTS
|
|
INC $7163 (-> stage 7)
|
|
draw text at $6C5F via $41C2 with A=1 ; "ETA" or similar
|
|
JSR $6C7F (scoreboard)
|
|
$68/$69 = $43B9; JSR $4354 ; BCD-add fare value to $07C2,Y
|
|
$68/$69 = $07E0; JSR $4354 ; second BCD add to a different field
|
|
JSR $43A5 (hudInit)
|
|
LDA $715C; BEQ -> one more $4354
|
|
RTS
|
|
```
|
|
|
|
So stage 6 performs **two BCD-add operations**: one onto the
|
|
in-cab display at `$07C2`, one at the global score field `$07E0`.
|
|
A third add fires when lives reach zero (the "final tally").
|
|
|
|
### Stage 7-9 -- HUD finalization + game-over
|
|
|
|
Stage 7 is just `JMP $6CE8` -- a HUD-flush routine. Stage 8 walks
|
|
the sprite ptr up to $CC again (a second cel-walk pass). Stage 9
|
|
zeroes `$7167` and `$7163` (clearing the state machine), runs
|
|
`$6877` (pad color alt), and draws the **game-over text** at
|
|
`$6C38` via `$41C2` with A=1.
|
|
|
|
So the full success animation flows: 0 -> 1 -> 2 -> 3 -> 4 -> 5 ->
|
|
6 -> 7 -> 8 -> 9 -> 0. The death path short-circuits from stage 1
|
|
straight to stage 8 to skip the scoring animation.
|
|
|
|
## Score storage and BCD math (`$4354`)
|
|
|
|
The 4-byte BCD score is rendered in screen-RAM at the HUD
|
|
positions. The `$4354` BCD-add helper:
|
|
|
|
```
|
|
$4354: Y = 6 ; $721A = 0 (carry scratch)
|
|
$4357: LDA ($68),Y ; source: fare value
|
|
JSR $4345 ; validate ASCII digit -> A (0..9 or special)
|
|
STA $6A ; per-digit value
|
|
LDA $07C2,Y ; current HUD char
|
|
JSR $4345 ; validate
|
|
CLC; ADC $6A; ADC $721A ; digit + source + carry
|
|
PHA; $721A = 0
|
|
PLA; CMP #$0A; BMI no_carry
|
|
INC $721A; SEC; SBC #$0A ; subtract 10, set carry
|
|
no_carry:
|
|
CLC; ADC #$6A ; offset back to char code ($6A = '0' code)
|
|
CMP #$6A; BNE store ; if result == $6A ('0'), use $74 (space)
|
|
LDA #$74 ; leading-zero suppression
|
|
store: STA $07C2,Y
|
|
DEY; BPL $438F ; next digit
|
|
...
|
|
```
|
|
|
|
So the BCD score is stored as **ASCII chars at $07C2-$07C8** (7
|
|
digits), with leading-zero suppression. The `$4345` helper validates
|
|
that source and dest chars are digits (or special $66 = leading
|
|
'0' tag, $6A = literal '0', $74 = space). The score lives directly
|
|
in screen RAM, not in a separate variable -- the chars displayed
|
|
ARE the score.
|
|
|
|
The fare-value blob at `$43B9` (or wherever $68/$69 points) is a
|
|
7-byte ASCII string representing the points to award; one BCD add
|
|
into the HUD adds those points. Different fares can have different
|
|
values by pointing $68/$69 at different blobs.
|
|
|
|
## Per-level data variations (cross-dump diff)
|
|
|
|
Comparing the four available dumps:
|
|
|
|
| Field | mem0000 | level1 | title | level2 |
|
|
| --------------------- | --------- | ---------- | ---------- | ---------- |
|
|
| `$7D8F` Y-accel | $1E (30) | $0E (14) | $19 (25) | $11 (17) |
|
|
| `$7D91` X-accel | $1E (30) | $0E (14) | $19 (25) | $11 (17) |
|
|
| `$7D93` Y-gravity | $03 ( 3) | $01 ( 1) | $06 ( 6) | $01 ( 1) |
|
|
| `$7D95` X-gravity | $00 ( 0) | $00 ( 0) | $00 ( 0) | $00 ( 0) |
|
|
| `$7D09` special pad | $03 | $01 | $05 | $03 |
|
|
| `$7D5A` (active pad?) | $03 | $01 | $05 | $03 |
|
|
|
|
Observations:
|
|
|
|
- **X gravity is always 0** -- no horizontal drift across all
|
|
observed dumps. Reasonable; no level has wind in the C64 game.
|
|
- **Y gravity varies**: level 1 has $01 (light), level 2 also $01,
|
|
the "title-demo" state ($19) has $06 (heavy). The mem0000 dump
|
|
($03) is likely an intermediate state during initialization.
|
|
- **Accel tracks gravity** roughly: heavier gravity needs more
|
|
thrust to fight it, hence $19 / $1E accel paired with $06 / $03
|
|
gravity. Level 1's gentle 14/1 is the most playable starting
|
|
point.
|
|
- `$7D09` and `$7D5A` correlate strongly -- both look like
|
|
pad-count + special-pad-index combinations.
|
|
|
|
Per-pad data starting at `$7D0A` has 8-byte stride. Title screen's
|
|
5 active pads ($7D5A=5) means a 40-byte pad-table region used.
|
|
This is the per-level fixed data that the C64 game ships with;
|
|
in the JoeyLib port, the equivalent comes from
|
|
`DATA/levels/level??.dat` files (24 levels, A..X).
|
|
|
|
### Per-pad byte layout (VERIFIED via reader-grep)
|
|
|
|
Pad table entries at `$7D0A,X*8`. Eight bytes per pad. Updated
|
|
after tracing the readers in `$645C` (padDetect) and `$6620`
|
|
(newFareTransition):
|
|
|
|
```
|
|
byte 0,1: cab-landing X start (16-bit, hi/lo)
|
|
byte 2,3: cab-landing X end (16-bit, hi/lo)
|
|
byte 4: cab-landing row Y ($7D0E,X)
|
|
byte 5: passenger spawn X fractional byte ($7D0F,X -> $7186)
|
|
byte 6: passenger spawn X column ($7D10,X -> $7176)
|
|
byte 7: UNUSED -- no code reads $7D11,X. The $C2/$C4 values
|
|
observed in dumps are dead bytes. My earlier "ground vs
|
|
elevated pad" interpretation was speculation.
|
|
```
|
|
|
|
Verified via `grep -nE "[0-7] 7D"` to find direct addressing modes
|
|
hitting `$7D0B` through `$7D11`. Only bytes 1, 4, 5, 6 are read at
|
|
runtime. Bytes 0, 2, 3 are accessed via the X-indexed pair with
|
|
byte 1 in the 16-bit compare. Byte 7 has no readers anywhere.
|
|
|
|
Level 2 has 3 pads at rows $44, $BC, $5C with style codes $C2/$C2/
|
|
$C4. Title-screen demo has 5 pads -- the "press start" art lays
|
|
out a logical city with that many landing spots.
|
|
|
|
For the JoeyLib port these decode to `StPadT.tileX/tileW/tileY` --
|
|
the high-byte difference (01 vs 00) maps onto the C64's 320-pixel
|
|
horizontal range, so converting to JoeyLib's 40-tile-wide field
|
|
just divides by tile_pixels.
|
|
|
|
## Frame budget summary
|
|
|
|
Total per-frame work from main-loop trace:
|
|
|
|
| Phase | Calls |
|
|
| -------- | ---------------------------------------------------- |
|
|
| Gated | 9 JSRs (physics + sprite-cel + pad-detect + audio) |
|
|
| Post-work | 16 JSRs (anim + flush + HUD + SFX envelope) |
|
|
| VBL syncs | 2x $404B between marshal and flush, second at end |
|
|
| IRQ | Music tick (~50us) |
|
|
|
|
The two `$404B` VBL waits are the explicit frame sync points. The
|
|
order is: gameplay state → animation → marshal sprite shadow →
|
|
WAIT VBL → flush shadow to hardware → continue post-work → WAIT
|
|
VBL → top of loop. So screen updates happen during vblank, but
|
|
game logic runs full-tilt between vblanks.
|
|
|
|
This is the "no fixed framerate" pattern: the music IRQ + raster
|
|
sync provide timing, but the main loop can run as many iterations
|
|
as it has time for between vblanks. On a stock C64 that's ~1 frame
|
|
per vblank; on accelerated hardware (or in JoeyLib's port) it
|
|
could be more.
|
|
|
|
## Per-level header (`$7D00-$7D08`) -- VIC colors
|
|
|
|
The bytes BEFORE the pad table are a VIC-II color block. The
|
|
scene-load routine at `$62F0` (called via `$621C` at round-end)
|
|
does:
|
|
|
|
```
|
|
JSR $4523 ; title-style sprite setup
|
|
JSR $42BC (memcpy) ; $7530 -> $0400, 1000 bytes (screen RAM)
|
|
JSR $42BC ; $7918 -> $D800, ~1000 bytes (color RAM)
|
|
LDX #$06
|
|
LDA $7D00,X -> $D020,X ; per-level header -> VIC color regs
|
|
DEX, BPL
|
|
LDA $7D07 -> $719F ; sprite-0 color shadow
|
|
LDA $7D08 -> $71A0 ; sprite-1 color shadow
|
|
LDX #$09; STA $7152,X (zero), DEX, BPL ; clear sprite slot table
|
|
STA $715C (zero!) ; lives counter cleared at scene-load
|
|
```
|
|
|
|
So `$7D00-$7D06` maps onto VIC registers `$D020-$D026`:
|
|
- `$7D00`: border color
|
|
- `$7D01`: background color (BG #0)
|
|
- `$7D02-04`: BG colors 1, 2, 3 (multi-color mode)
|
|
- `$7D05-06`: sprite multicolor 0, 1
|
|
- `$7D07-08`: sprite-0/1 individual colors
|
|
|
|
The decompressed scene data lives at `$7530-$7929` (screen) and
|
|
`$7918+` (color), staged before being copied into screen RAM.
|
|
These are output buffers of the decompressor at `$9656`.
|
|
|
|
## Stage 3 indirect vector (`$5DCE` -- runtime patched)
|
|
|
|
The dispatcher at `$66DA` (stage 3) does `INX; JMP ($5DCE)`. In
|
|
all four available dumps, bytes at `$5DCE/$5DCF` are `00 00`,
|
|
which means JMP indirect to `$0000` (= BRK -- a crash). Since
|
|
gameplay clearly works, the vector must be **patched at level-init
|
|
time** by the bank-switched decompressor at `$9656`.
|
|
|
|
Each level's compressed data (under the Kernal ROM) likely includes
|
|
a per-level "stage 3 hook" address that gets written into `$5DCE`
|
|
when the scene loads. Different levels can have different "success"
|
|
animations by pointing the vector at level-specific code.
|
|
|
|
Without a VICE dump that captures the under-Kernal RAM (BASIC out,
|
|
Kernal out before saving), we can't see the decompressor or the
|
|
per-level patches. The `dumpAllLevels.py` script crashes at PC
|
|
`$9735` because the dumps we have show ROM contents at $A000+
|
|
instead of the game's hidden code (opcode `$02` is invalid 6502 =
|
|
KIL, occurs when we try to run code from inside the BASIC ROM).
|
|
|
|
**Workaround for the port**: in JoeyLib, stage 3 isn't reproduced
|
|
verbatim. Our `stEngine.c` death-anim is a simpler state machine
|
|
that doesn't need this indirection.
|
|
|
|
## Music engine effect commands (`$CD56`)
|
|
|
|
Reached from `$CC8D` when the track byte has its high bit set AND
|
|
the masked value is >= $61. The effect byte is bit-shuffled at
|
|
`$CD58-$CD61` (ASL ASL ASL with C save/restore via PHP/PLP, then
|
|
ROR LSR LSR) -- this rearranges the byte to extract the effect
|
|
index. The result is then compared against thresholds:
|
|
|
|
| A value | Target | Effect |
|
|
| ------- | ------- | --------------------------------------------------- |
|
|
| < $04 | $CE45 | "track-call from arg ptr" (push current, jump) |
|
|
| < $08 | $CE13 | "track-return" (pop saved ptr from $CB95/96,Y) |
|
|
| < $0C | $CDE4 | decrement repeat counter at $CB85,X |
|
|
| < $10 | $CDD0 | decrement filter mask at $CBAA+Y |
|
|
| < $18 | $CDBF | set per-voice default duration ($CB9B,X) |
|
|
| < $20 | $CDAE | set per-voice transpose ($CB86,X) |
|
|
| < $30 | $CD9A | set per-voice filter mask byte ($CBAA+Y) |
|
|
| == $30 | $CD71 | bulk-set 14 bytes of ADSR/pulse-width ($CBBF+X..) |
|
|
| > $30 | $CD68 | "skip two bytes" silent advance |
|
|
|
|
The `JSR $CD43` helper advances the track pointer by 1 byte; it's
|
|
used to consume the effect byte + any arg bytes. After processing
|
|
an effect, control returns via `JMP $CDA8` (continue advancing
|
|
track) or `JMP $CC99` (restart note-start with new track ptr after
|
|
a track-call).
|
|
|
|
So the supported effects are roughly: track-call, track-return,
|
|
repeat-count, set transpose, set duration, set filter routing,
|
|
load instrument (14-byte ADSR/pulse-width block), and "skip".
|
|
This is a competent multi-pattern tracker with subroutine support.
|
|
|
|
For the JoeyLib port: approximate timbres are fine. We don't need
|
|
to reproduce the full effect set; the simple "note + duration"
|
|
stream covers most of the music with reasonable fidelity.
|
|
|
|
## Title-screen sprite animator (`$4523-$45A7`)
|
|
|
|
Entry via `JSR $4523` from `$62F0` (scene-load). The 4-byte LDA
|
|
#$C0 / STA $719E sets sprite-7 ptr; then setup at `$4525` for the
|
|
title sprite cloud. Falls through to the pseudo-random demo
|
|
animator at `$4666`:
|
|
|
|
```
|
|
$4666 loop top:
|
|
X = $68 (current sprite index)
|
|
if $44F8,X (per-sprite timer) == 0:
|
|
JMP $4711 ; "exit demo" -- only when all timers zero
|
|
DEC $44F8,X
|
|
if non-zero, skip respawn -> $46A0
|
|
respawn at $4675:
|
|
$718E,X = 1 ; mark sprite "visible"
|
|
$7185,X = 0 ; clear X-frac
|
|
new X = ($717D & 7) + $A6 ; columns $A6..$AD (8 wide)
|
|
$7175,X = new X
|
|
new Y = (($717D & $30) >> 4) + $89 ; 4 vertical bands at $89/$8A/$8B/$8C
|
|
$717D,X = new Y
|
|
$4500,X = $20 ; reload counter
|
|
$46A0:
|
|
DEC $68; BPL $4666 -- loop X = 6 down to 0
|
|
$46A4: JSR $4253 (shadow marshal)
|
|
JSR $404B (VBL wait)
|
|
JSR $4293 (shadow flush)
|
|
$7222 = 0
|
|
JSR $4FCB (runStopWatcher)
|
|
if $7222 != 0: JSR $CB02 with A=$08 (restart music)
|
|
$46BF: INC $4508 (loop counter); ...
|
|
```
|
|
|
|
So the title-demo is **per-sprite countdown timers**: each of the
|
|
7 demo sprites has a counter at `$44F8,X` and a respawn counter at
|
|
`$4500,X`. When the per-sprite timer hits zero, the sprite respawns
|
|
at a pseudo-random position derived from the taxi row (`$717D`)
|
|
LSBs -- so the "randomness" is actually driven by the demo cab's
|
|
own motion. Once all sprites have zero timer, JMP $4711 transitions
|
|
to gameplay.
|
|
|
|
The per-direction respawn position is reproducible (deterministic
|
|
given the same $717D sequence) so the original always shows the
|
|
same demo, frame-perfect.
|
|
|
|
For the JoeyLib port: render the title screen as a static image,
|
|
or implement the same per-sprite-timer-respawn pattern with
|
|
positions derived from a frame counter. Either works; the latter
|
|
matches the original's quirky-but-charming flicker effect.
|
|
|
|
## Score / fare-value blobs (`$43B1` table) -- VERIFIED via emulator trace
|
|
|
|
Four pre-defined 7-byte fare-value blobs at `$43B1, $43B9, $43C1,
|
|
$43C9`. Each blob is a 7-char screen-RAM template:
|
|
|
|
```
|
|
$43B1: 66 66 66 74 77 74 74 ; HUD init (all blanks + separator)
|
|
$43B9: 66 66 66 6F 77 74 74 ; basic fare = +5
|
|
$43C1: 66 66 73 6F 77 74 74 ; alt fare A = +95
|
|
$43C9: 66 66 6F 74 77 74 74 ; alt fare B = +50
|
|
```
|
|
|
|
Char-code interpretation (via $4345 validation):
|
|
- `$66`: leading blank (treated as 0 in BCD add)
|
|
- `$74`: trailing space (treated as 0)
|
|
- `$6A-$73`: digits 0..9 ($6A = '0', $73 = '9')
|
|
- `$77`: separator (position 4 -- BCD-add SKIPS this position so
|
|
the separator char is preserved across adds)
|
|
- Other chars: undefined behavior
|
|
|
|
Verified by running `$4354` through the 6502 emulator (see
|
|
`stuff/spacetaxi/trace.py`) with each blob against an all-blank
|
|
HUD. The actual byte-level result for each:
|
|
|
|
| Blob | HUD after BCD-add (chars) | Numeric effect |
|
|
| ------ | ------------------------- | --------------------------- |
|
|
| $43B1 | `___ _ ` | identity (no digits) |
|
|
| $43B9 | `___5_ ` | +5 in pos 3 (ones) |
|
|
| $43C1 | `__95_ ` | +95 in pos 2 (tens) + pos 3 |
|
|
| $43C9 | `__5 _ ` | +50 in pos 2 (tens) |
|
|
|
|
If the C64 HUD layout is `DDDD.DD` (4 integer + separator + 2
|
|
decimal), then position 3 is the ones-digit of integer. So:
|
|
- `$43B9` = $5 basic fare
|
|
- `$43C9` = $50 bonus
|
|
- `$43C1` = $95 jackpot
|
|
|
|
Callers (verified in disassembly):
|
|
|
|
| Site | Blob | Game event |
|
|
| ------- | ------ | ------------------------------------------------ |
|
|
| `$6780` `$43B9` | Stage 6 ($6742) basic fare delivered |
|
|
| `$5C82` `$43C9` | After death-fall reaches floor row $D4 (untraced precise trigger) |
|
|
| `$5D03` `$43C1` | After per-passenger counter decrement hits zero (untraced precise trigger) |
|
|
|
|
The `$5C82` and `$5D03` precise game-state triggers are not yet
|
|
fully traced -- they fire during animation/state-transition flows
|
|
whose state pre-conditions aren't fully mapped.
|
|
|
|
For the JoeyLib port: `ST_FARE_SCORE = 5` per delivered fare
|
|
(matches verified `$43B9` BCD effect). The +50 and +95 bonuses
|
|
aren't wired up since their trigger conditions are unverified.
|
|
|
|
**Previously I claimed "flat 500 per fare" without ever running
|
|
the BCD math; that was wrong.**
|
|
|
|
## Game-end condition VERIFIED: $71CE == $7213 (fare target)
|
|
|
|
`$71CE` ("fares done") increments only at `$6BD6` inside `$6BD0`,
|
|
which is reached only when `$5CF1 >= 3` (the rare end-of-sequence
|
|
state). When `$71CE == $7213`, `$6BE1: JMP $602F` fires, which
|
|
`JMP $5EC4` -- the **gameInitContinue** entry, effectively
|
|
restarting the game (zeroes `$71CE`, `$7215`, runs title path).
|
|
|
|
`$7213` (fare target) is **player-selectable on the title screen**:
|
|
- `$48AF` sets it to `$01` default
|
|
- `$5310-$5328` is a title-screen UI loop:
|
|
- RIGHT held (`$7169 & $08`): INC `$7213`, mask to 0..3 (= 0..3 + 1 baseline = 1..4)
|
|
- LEFT held (`$7169 & $04`): DEC `$7213`
|
|
- Other input: continue
|
|
|
|
So Space Taxi's "difficulty" = how many fares per game. Default 1,
|
|
selectable 1..4 at title via joystick. After that many deliveries,
|
|
the game ends and returns to title.
|
|
|
|
The JoeyLib port's per-level fare progression (24 sequential
|
|
levels with their own fare counts) is a port-side invention --
|
|
the C64 game restarts at title after the player-chosen fare count,
|
|
with no inter-level progression.
|
|
|
|
## `$5C2C` drop-off animation (NOT death-fall -- VERIFIED)
|
|
|
|
Earlier I mis-labeled `$5C2C` as "deathFallAnim". Re-tracing
|
|
shows: when `$5CF1 = 0` (the default state), `$5BBB` jumps to
|
|
`$5C2C` which runs physics each tick until the cab's row reaches
|
|
`$D4` (row 212 = near screen bottom). Then it executes the
|
|
"finalize" block:
|
|
|
|
```
|
|
$5C77: $68=$E0; $69=$07 ; source pointer = $07E0 (HUD blank)
|
|
$5C7F: JSR $4354 ; BCD-add $07E0 to $07C2 -- noop (source is blanks)
|
|
$5C82: $68=$C9; $69=$43 ; source pointer = $43C9 (+50 fare blob)
|
|
$5C8A: JSR $4354 ; BCD-add: $07C2 += 50
|
|
$5C8D: JSR $43A5 ; hudInit -- re-template $07E0
|
|
$5C90: RTS ; back to caller
|
|
```
|
|
|
|
Verified by emulator trace: starting cab at row `$D3`, running
|
|
`$5C2C` directly. 642 instructions executed. Cab ends at `$D4`.
|
|
JSR chain captured: $4354 (twice) and $43A5 (hudInit). Score at
|
|
$07C2 advances by +50 (the `$43C9` blob delta verified earlier).
|
|
|
|
So **`$5C2C` is the drop-off animation**, awarding +50 (the bonus
|
|
fare blob) once the cab reaches the destination pad floor. NOT a
|
|
death sequence -- the cab moves to its target, gets +50 score, the
|
|
fare is delivered.
|
|
|
|
The `$5CF1` state-selector ranges in `$5BBB`:
|
|
|
|
| $5CF1 | Path | Role |
|
|
| ----- | -------------- | ------------------------------------- |
|
|
| 0 | `$5C2C` | drop-off animation (+50 bonus on finalize) |
|
|
| 1 | `$5C91` | takeoff animation |
|
|
| 2 | `$5CB9` | pad-to-pad transition |
|
|
| 3+ | fall through to `$5BD1` | restore physics templates, re-enable trampolines, then `JMP $6BD0` |
|
|
|
|
So `$6BD0` is reached ONLY when `$5CF1 >= 3` -- a rare state that's
|
|
probably the actual game-over path. The "GAME OVER!" text drawn by
|
|
`$5FBB` therefore does NOT display on every drop-off as I claimed
|
|
earlier; only on the specific `$5CF1 >= 3` transition.
|
|
|
|
## Level-end / game-over flow
|
|
|
|
After phase-3 finishes (`$6B4C`), control falls to `$6BD0`
|
|
(reached via the `$5BD1` template-restore path, not every death):
|
|
|
|
```
|
|
$6BD0: JSR $704E ; archive HUD row + score row to backup tables
|
|
$6BD3: JSR $5FBB ; (if game-over) draw "GAME OVER" + delay
|
|
$6BD6: INC $71CE ; bump fares-completed counter
|
|
$6BD9: CMP $7213 ; compare to fares-needed
|
|
$6BDF: BNE $6BE4 -> JMP $5F02 ; not done, main loop
|
|
$6BE1: JMP $602F ; all fares done, level complete
|
|
$6BE4: JMP $5F02
|
|
```
|
|
|
|
`$704E` archives the current HUD: copies the 7 chars at `$07C2`
|
|
(in-cab text) and 7 chars at `$07E0` (score row) into per-passenger
|
|
backup tables at `$71D3,X*8` and `$71F3,X*8` (X = current fare idx
|
|
$7214). This lets a respawn restore the per-fare HUD state.
|
|
|
|
`$5FBB` (drawn only via `$6BD3` on game-over path) renders the
|
|
**"GAME OVER" screen**:
|
|
- Three lines at column 14, rows 10/11/12 drawn via `$41C2` from
|
|
source pointers `$6C38`, `$6C52`, `$6C38`
|
|
- `$D01B = $FF` (push all sprites BEHIND the background)
|
|
- Kill voice 1+2 control + voice 3 gate-off
|
|
- Four sequential busywait loops at `$4101` with X=$FF each
|
|
(~80ms total visual pause)
|
|
|
|
`$5010` is the **level-end dispatcher** called from `$61FB` round-end:
|
|
|
|
| State | Action |
|
|
| ---------------------------------- | -------------------- |
|
|
| `$5E9D` non-zero (post-mortem) | JMP $505A (full game-over screen) |
|
|
| `$721C` non-zero (menu mode) | JMP $50C6 |
|
|
| `$7215 == $18` | JMP $5167 (level-complete pause) |
|
|
| `$7215 == $19` | JMP $602F (next-level intro) |
|
|
| `$7221 == 1/2/3` | JMP $517D (sub-paths) |
|
|
| `$7221 == 4` | JMP $519C |
|
|
| `$7221 == 5` | JMP $51A6 |
|
|
| else | JMP $5EC4 (gameInitContinue) |
|
|
|
|
So `$7215` is the level-progression counter ($18 = "level done, show
|
|
score"; $19 = "advance to next level") and `$7221` is a sub-state
|
|
selector for finer-grained transitions.
|
|
|
|
`$5BBB` is the **respawn-state dispatcher** that branches on `$5CF1`:
|
|
|
|
| `$5CF1` | Path |
|
|
| ------- | ------------------------------------------------------ |
|
|
| 0 | JMP $5C2C (death fall anim: $6032 physics + clamp $D4) |
|
|
| 1 | JMP $5C91 (takeoff anim: $6032 physics + clamp <$87) |
|
|
| 2 | JMP $5CB9 (pad-to-pad fly-by sequence) |
|
|
| else | Falls through to $5BD1 (level template restore) |
|
|
|
|
So `$5CF1` (earlier labeled gCacheUnknown) is the **transition-type
|
|
selector**: 0=falling, 1=taking off, 2=moving between pads,
|
|
else=normal level init.
|
|
|
|
## Music index cycling (`$6279`)
|
|
|
|
`$6279` decrements `$716B` (music song index) by `$41`. Called from
|
|
`$621F` round-end-reset. Each fare completion rotates the song:
|
|
each level isn't tied to one song; the music shifts as the player
|
|
progresses through fares within a level.
|
|
|
|
## Game-init self-popping trampoline (`$4741`)
|
|
|
|
```
|
|
$4741: PLA / STA $473C ; save caller's return-addr LO
|
|
$4745: PLA / STA $473D ; save HI
|
|
$4749: TSX / STX $473E ; save stack pointer
|
|
$474D: JSR $477A ; load song 25 via $6283 + JSR $6F95
|
|
$4750: A=$80 -> $D412 (voice 3 off)
|
|
D015=0, D020=D021=0, $C6=0
|
|
JSR $40CA (fillScreenAndColor)
|
|
if $721C != 0: JSR $5295 (score-screen draw)
|
|
$476D: TSX/TXS restore ; restore stack pointer
|
|
Push saved return addr back
|
|
$4779: RTS ; return to caller as if nothing happened
|
|
```
|
|
|
|
This is a clever **JSR-but-don't-consume-stack** pattern. The
|
|
routine pops its own return address, does setup with possibly
|
|
modified call chains, then pushes the original return back. The
|
|
net effect is that the caller never knows $4741 ran -- but $4741
|
|
did get to insert title-screen song setup + buffer fill.
|
|
|
|
Called from `$5EF7` in gameInit. So gameInit goes through:
|
|
```
|
|
$5EC1 gameInit
|
|
-> $CC00 install IRQ
|
|
-> ... clear buffers
|
|
-> $5EEC JSR $528A (zero $5254 buffer)
|
|
-> if $5E9D set: JMP $48AD (title)
|
|
-> $5EF7 JSR $4741 (transparent song-25 + clear setup)
|
|
-> if $721C set: JMP $48AD (title)
|
|
-> $5F02 main entry...
|
|
```
|
|
|
|
## Under-ROM data (from raw.bin)
|
|
|
|
The `raw.bin` capture was taken with `bank ram` set in VICE so the
|
|
RAM hidden beneath the BASIC/Kernal/I/O ROMs is preserved. With it
|
|
we can see:
|
|
|
|
- The decompressor at `$9656` and its helper bit-stream reader at
|
|
`$96A3/$964A`
|
|
- The scene pointer table at `$9600` (28 entries, 2 bytes each)
|
|
- The compressed scene data at $D000-$FCFF and $8100-$9418
|
|
- The music engine code at $CB00-$CFFF and frequency tables at
|
|
$CE90 (lo bytes) / $CEF1 (hi bytes)
|
|
|
|
### Scene pointer table (`$9600`)
|
|
|
|
24 game levels (A..X = scenes 0..23) + special scenes:
|
|
|
|
| Scene | Letter | Data addr |
|
|
| ----- | ------ | --------- |
|
|
| 0 | A | $D000 |
|
|
| 1 | B | $D1E4 |
|
|
| 2 | C | $D3E0 |
|
|
| 3 | D | $D700 |
|
|
| 4 | E | $D8F5 |
|
|
| 5 | F | $DB15 |
|
|
| 6 | G | $DD77 |
|
|
| 7 | H | $E075 |
|
|
| 8 | I | $E416 |
|
|
| 9 | J | $E6F6 |
|
|
| 10 | K | $E9BF |
|
|
| 11 | L | $EBCE |
|
|
| 12 | M | $EE79 |
|
|
| 13 | N | $F05F |
|
|
| 14 | O | $F251 |
|
|
| 15 | P | $F46D |
|
|
| 16 | Q | $F767 |
|
|
| 17 | R | $FA61 |
|
|
| 18 | S | $FC9F |
|
|
| 19 | T | $8100 |
|
|
| 20 | U | $8340 |
|
|
| 21 | V | $86A7 |
|
|
| 22 | W | $8943 |
|
|
| 23 | X | $8C7B |
|
|
| 24 | title | $8F7D |
|
|
| 25 | ? | $9418 |
|
|
| 26+ | invalid | (data garbage) |
|
|
|
|
### Decompressor (`$9656`)
|
|
|
|
```
|
|
$9656: TXA / ASL A / TAX ; X = scene*2 (word index)
|
|
$9659: LDA $9600,X ; load LO of pointer
|
|
$965C: LDY $9601,X ; load HI
|
|
$965F: STA $964B / STY $964C ; self-mod the LDA at $964A
|
|
$9665: LDY=0; LDX=3
|
|
$9669: JSR $964A ; read 1 bit from stream
|
|
STA $93,X; DEX; BNE ; build 4-bit token
|
|
$9672: TYA; AND #$0F
|
|
BEQ $968B ; skip if zero
|
|
$9677: ... shift/copy table-build ...
|
|
$968B: STA $039C,Y ; write to output table
|
|
STA $0368,Y ; output a second table too
|
|
JSR $96A3 (read 4 bits) -> $0334,Y
|
|
$969A: INY; CPY #$34; BNE $9671
|
|
... walk output ...
|
|
```
|
|
|
|
It's a per-scene **bitstream decoder** that builds three tables at
|
|
$0334, $0368, $039C from a compressed stream. Each scene blob is
|
|
roughly 250-700 bytes that expands to 1000 cells of screen RAM,
|
|
1000 cells of color RAM, plus the per-level data table at $7D00.
|
|
|
|
The decompressor uses self-modification for the read pointer
|
|
($964B/$964C are operand bytes of the LDA at $964A), enabling
|
|
the same code to walk any scene's pointer.
|
|
|
|
### Per-level data table for all 24 levels (VERIFIED via emulator)
|
|
|
|
Extracted by running the decompressor for each scene through
|
|
`cpu6502.py` and reading `$7D00-$7D9F` after. X-accel and Y-accel
|
|
are stored separately (`$7D91/92` X, `$7D8F/90` Y) and DIFFER on
|
|
several levels. X-gravity (`$7D95`) is non-zero on levels P and U
|
|
(side-wind). Y-gravity (`$7D93`) is signed -- level K = `$F9` = -7.
|
|
|
|
| Lvl | Pads | SpawnX | SpawnY | X-acc | Y-acc | X-grav | Y-grav | Bord/BG |
|
|
| --- | ---- | ------ | ------ | ----- | ----- | ------ | ------ | -------- |
|
|
| A | 1 | $0A00 | $4A00 | $0E | $0E | $00 | $01 | $00/$00 |
|
|
| B | 3 | $8800 | $8800 | $11 | $11 | $00 | $01 | $00/$00 |
|
|
| C | 5 | $AA00 | $4400 | $12 | $10 | $00 | $02 | $00/$00 |
|
|
| D | 10 | $8800 | $8800 | $11 | $14 | $00 | $06 | $00/$00 |
|
|
| E | 9 | $FA00 | $C800 | $11 | $15 | $00 | $06 | $09/$00 |
|
|
| F | 3 | $AA00 | $6400 | $15 | $13 | $00 | $03 | $00/$00 |
|
|
| G | 5 | $AF00 | $5600 | $14 | $18 | $00 | $06 | $0C/$00 |
|
|
| H | 5 | $9600 | $3E00 | $19 | $19 | $00 | $06 | $00/$00 |
|
|
| I | 7 | $AA00 | $6E00 | $15 | $13 | $00 | $03 | $00/$00 |
|
|
| J | 6 | $1E00 | $3E00 | $19 | $19 | $00 | $05 | $09/$00 |
|
|
| K | 5 | $B400 | $5F00 | $17 | $15 | $00 | $F9 | $00/$00 | <- anti-gravity
|
|
| L | 6 | $6E00 | $4600 | $1A | $1A | $00 | $03 | $0B/$0B |
|
|
| M | 10 | $AF00 | $3E00 | $40 | $46 | $00 | $06 | $06/$00 | <- highest accel
|
|
| N | 8 | $AF00 | $3E00 | $12 | $16 | $00 | $06 | $00/$00 |
|
|
| O | 1 | $2E00 | $3400 | $15 | $13 | $00 | $03 | $00/$00 |
|
|
| P | 3 | $2400 | $4A00 | $15 | $15 | **$04**| $06 | $0B/$00 | <- side wind
|
|
| Q | 8 | $B400 | $4600 | $19 | $19 | $00 | $05 | $0B/$00 |
|
|
| R | 1 | $E600 | $3800 | $10 | $10 | $00 | $01 | $00/$00 |
|
|
| S | 5 | $AA00 | $4400 | $12 | $10 | $00 | $03 | $07/$08 |
|
|
| T | 8 | $AF00 | $C800 | $1B | $26 | $00 | $06 | $06/$00 |
|
|
| U | 5 | $AA00 | $4A00 | $18 | $18 | **$02**| $02 | $00/$00 | <- side wind
|
|
| V | 7 | $AF00 | $3E00 | $1E | $20 | $00 | $06 | $06/$00 |
|
|
| W | 3 | $3200 | $4600 | $1E | $1E | $00 | $03 | $00/$00 |
|
|
| X | 8 | $AF00 | $4600 | $17 | $17 | $00 | $06 | $0B/$00 |
|
|
|
|
Sprite colors (`$7D07`/`$7D08`) verified `$06` (blue) for EVERY
|
|
level -- so the cab color is constant across the game; only the
|
|
border/BG palette changes.
|
|
|
|
Key gameplay-affecting observations:
|
|
|
|
- **Levels P and U have side-wind**: non-zero X-gravity (`$04` and
|
|
`$02` respectively). Cab drifts horizontally without input.
|
|
- **Level K has anti-gravity** (`Y-grav = $F9` = -7 signed). The
|
|
cab is pulled UP each frame.
|
|
- **X-accel != Y-accel on most levels**. The cab is more responsive
|
|
on one axis. E.g., level T: xAcc=$1B but yAcc=$26 (38% more
|
|
vertical thrust). Level M is the extreme: x=$40, y=$46.
|
|
- **Pad count** grows from 1 (A, O, R) to 10 (D, M).
|
|
- **Border/BG colors** vary; most levels are black (0/0). Level S
|
|
has yellow/orange (`$07/$08`), level L is dark gray on dark gray.
|
|
|
|
The .dat files in `examples/spacetaxi/generated/levels/` carry the
|
|
same xAccel/yAccel/xGrav/yGrav values verbatim (verified by parsing
|
|
the binary headers).
|
|
|
|
**Pad style bytes (byte 7 of each 8-byte pad slot):**
|
|
|
|
Level | Style bytes
|
|
--- | ---
|
|
A | $C2
|
|
B | $C2 $C2 $C4
|
|
C | $C2 $C2 $C4 $C2 $C4
|
|
D | $FF $FF $FF $FF $FF $FF $FF $FF $FF $FF (uninitialized?)
|
|
E | $C2 $C2 $C4 $C2 $C4 $C2 $C4 $C2
|
|
F | $C2 $C4 $C4
|
|
G | $C2 $C2 $C2 $C2 $C4
|
|
H | $C4 $C4 $C2 $C2 $C2
|
|
I | $C2 $C2 $C2 $C2 $C4 $C4 $C4
|
|
J | $C2 $C2 $C4 $C2 $C4 $C4
|
|
K | $C2 $C2 $C2 $C4 $C4
|
|
L | $C2 $C4 $C2 $C2 $C4 $C4
|
|
M | $C2 $C2 $C4 $C4 $C4 $C4 $C4 $C2
|
|
N | $C4 $C2 $C2 $C4 $C4 $C4 $C2 $C2
|
|
O | $C2
|
|
P | $C2 $C4 $C2
|
|
Q | $C4 $C2 $C2 $C4 $C2 $C4 $C2 $C4
|
|
R | $C4
|
|
S | $C2 $C2 $C4 $C2 $C4
|
|
T | $C2 $C4 $C2 $C2 $C4 $C4 $C2 $C4
|
|
U | $C4 $C2 $C2 $C4 $C2
|
|
V | $C2 $C2 $C2 $C2 $C2 $C4 $C4
|
|
W | $C4 $C2 $C2
|
|
X | $C4 $C4 $C4 $C4 $C2 $C2 $C2 $C2
|
|
|
|
Pad-style codes only vary between `$C2` (mostly) and `$C4`. Level
|
|
D's all-`$FF` is suspicious -- either uninitialized scene data or
|
|
a sentinel meaning "use default style".
|
|
|
|
## Stage 3 is dead code (the `$5DCE` mystery resolved)
|
|
|
|
Empirically confirmed via grep across BOTH the visible RAM listing
|
|
AND the under-ROM listing: **no code writes to `$5DCE` or `$5DCF`**.
|
|
The bytes stay at $00 $00 throughout execution, so `JMP ($5DCE)`
|
|
at stage 3's `$66DA` would jump to address $0000 = BRK = crash.
|
|
|
|
This is fine because **stage 3 of the `$65C1` dispatcher is never
|
|
reached during normal play**: stage 2 of $65C1 (`$66B7`) is just
|
|
a cel-cycle loop with no `INC $7163`, so the state machine sticks
|
|
at stage 2 and never tries to advance to 3.
|
|
|
|
Stages 1 through 9 ARE reached, but via a **parallel state machine
|
|
at `$4A03`** (driven from the score-screen / level-intro path at
|
|
`$47C9`+ that sets `$7163 = 2` directly). $4A03's dispatch handles
|
|
stages 2/3/4/5 by calling specific subroutines (e.g. stage 2 calls
|
|
`$66B7` as a SUBROUTINE rather than via the $65C1 dispatch). Stage
|
|
3 of $4A03 (`$4A24`) is a hover-style routine that doesn't use
|
|
$5DCE at all.
|
|
|
|
The `INX / JMP ($5DCE)` at `$66DA` is **legacy code from an earlier
|
|
version** where the state machines were unified. Now it's just an
|
|
unreachable trap.
|
|
|
|
### Parallel state machine (`$4A03`)
|
|
|
|
Dispatcher with its own jump table on `$7163`:
|
|
|
|
| `$7163` | Target | Role |
|
|
| ------- | ------- | ----------------------------------------------- |
|
|
| 2 | $4A17 | sparkle-cel cycle ($66B7) + 90-frame wait |
|
|
| 3 | $4A24 | hover positioning (uses $6D0D pad-lookup) |
|
|
| 4 | $4A45 | JMP $67A6 (sprite ptr walk to $CC) |
|
|
| 5 | $4A48 | final hover + noise gate-off |
|
|
|
|
This runs as a **sequence** during the score-screen / "press fire
|
|
to start" transition, called from `$47E4: JSR $4A03`. Caller sets
|
|
`$7163 = 2` and `$473F = $5A` (90-frame counter), then runs the
|
|
machine via JSRs (not main-loop dispatch).
|
|
|
|
So Space Taxi has **TWO state machines on $7163**:
|
|
- `$65C1` runs every frame from post-tick (stages 0-9, but only
|
|
0/1/2/4-9 are reachable; the 2->3 path is broken)
|
|
- `$4A03` runs from the intro/transition path (stages 2-5, fully
|
|
reachable)
|
|
|
|
## Music frequency tables (under-ROM)
|
|
|
|
- `$CE90-$CEF0`: 97 entries, **freq LO byte per note**
|
|
- `$CEF1-$CF51`: 97 entries, **freq HI byte per note**
|
|
|
|
The note indices 0..96 cover roughly 8 octaves. Note 0 = $0000
|
|
(silence), notes 1..96 give the SID 16-bit frequency code for each
|
|
chromatic step. Looking up: at index 13 (octave 1, note D) the
|
|
freq is $0238 = 568, which translates via the SID formula to
|
|
~33 Hz -- low D. The table goes up to ~3.5 kHz at the top.
|
|
|
|
These tables are normal 12-tone equal temperament with the C64
|
|
SID's clock-divider scaling. For the JoeyLib port: any standard
|
|
PSG / FM / sample-based pitch table will sound musically correct.
|
|
|
|
## Open work (truly remaining)
|
|
|
|
1. **Bonus / tip score blobs**: blobs at `$43C1` and `$43C9` are
|
|
not yet traced to their callers. The flat-rate 500-point blob
|
|
($43B9) is the primary; the other two are likely triggered by
|
|
specific event scenarios we haven't observed.
|
|
2. **Music engine effect arg-byte semantics**: the major effect
|
|
types are mapped, but the exact byte-layout of each effect's
|
|
args (how many bytes after the opcode, what they mean) is
|
|
still case-by-case. Approximate playback works without this.
|
|
3. **Menu-state script interpreter** at `$48F2-$494F`: consumes a
|
|
byte stream from the `$0900` buffer. The opcode table for the
|
|
script bytes (timing, text display, animation triggers) needs
|
|
to be traced opcode-by-opcode by stepping through the title
|
|
demo in VICE.
|
|
4. **Level D's `$FF` pad-style placeholder**: byte-7 of all 10 pad
|
|
slots is `$FF` in level D. Either a level data bug in the
|
|
original game or the game substitutes a default style when it
|
|
sees $FF. Worth confirming by playing level D in VICE.
|