commit 6804baa6c132798dc9b01ec411d2b2543d5f68a8 Author: Scott Duensing Date: Wed May 27 20:22:24 2026 -0500 Optimized a bit better. Examples are a mess of broken crap. diff --git a/MECHANICS.md b/MECHANICS.md new file mode 100644 index 0000000..4310a98 --- /dev/null +++ b/MECHANICS.md @@ -0,0 +1,2134 @@ +# 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 `. +- 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 ) +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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..0bbd953 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,141 @@ +# Space Taxi port plan + +Roadmap for bringing the JoeyLib port to functional parity with the +C64 original. Sequenced so each phase delivers something testable on +its own and unblocks the next. Each phase ends with a user-test +checkpoint -- regressions must be caught before moving on. + +## Workflow per subsystem + +Same loop every time, so it stays cheap to iterate: + +1. **Find** -- grep the live RAM dump for relevant register writes + (`$DC00`, `$D015`, `$D400-$D418`, `$07F8-$07FF`, etc.) or use + `callGraph.py` to find routines touching specific addresses. +2. **Read** -- pull the routine into a window via `disLive.py + `, study the annotated listing. +3. **Document** -- append behavior summary to `MECHANICS.md` in + plain English. Add new labels/vars to `stuff/spacetaxi/labels.txt`. +4. **Reannotate** -- `python3 disLive.py mem0000-level1.bin + live-level1.lst` to regenerate the (annotated) listing from + the dump + labels.txt in one step. The .lst file is the only + readable form -- labels are baked in directly. +5. **Port** -- update the matching JoeyLib C file + (`stEngine.c`, `stPassenger.c`, `stRender.c`, `stAudio.c`, + `stLevel.c`, `spacetaxi.c`). +6. **Build + ship** -- `make -f make/dos.mk EXAMPLE=spacetaxi` + first, then once stable also amiga / atarist / iigs. +7. **User test** -- run `scripts/run-dos.sh staxi`, report + behavior. Do not start the next phase before this. + +## Phase 0 -- foundation (DONE) + +- Disassembly toolchain in `stuff/spacetaxi/` +- Full live-RAM listing + annotated variant +- `MECHANICS.md` baseline +- Asset pipeline (`tools/assetbake/assetbake.py`, extractor realigned) +- Bugfixes: sprite size in tile units, palette clobber, + tile-bank load clamp, fire-not-thrust, palette/sheet collision +- Template-velocity physics in `stEngine.c` + +## Phase 1 -- physics + collision (NEXT) + +Make the cab fly correctly. Highest impact: nothing else works +until the player can hover, land, and crash. + +| Task | Investigate | Update | +| ---- | ----------- | ------------------------------------- | +| 1.1 | Trace `$7D95/96` gravity word writers -- when is gravity on? | `stEngine.c`: gate gravity on the same conditions | +| 1.2 | Find writers of `$721D` (player state byte). High bit = "dying". What sets it? | `stEngine.c`: crash trigger logic | +| 1.3 | Trace phase-2 handler at `$6B24` -- this is what runs after `$6A72` advances. Likely the crash / fall-to-ground sequence | New `stEngine.c` crash-anim path | +| 1.4 | Find pad-vs-wall predicate. Hardware `$D01F` says "we hit bg"; some code looks at char codes near taxi feet to decide PAD vs WALL | `stLevel.c`: pad detection by tile code | +| 1.5 | Confirm screen-edge wrap at `$6AED` -- when X-velocity carries the taxi off the right edge, does it wrap or stop? | `stEngine.c`: edge behavior | + +**Checkpoint**: cab flies smoothly, can land on a pad, crashes hard. + +## Phase 2 -- visuals + animation + +Make the screen match the original's look frame-by-frame. + +| Task | Investigate | Update | +| ---- | ----------- | ------------------------------------- | +| 2.1 | Confirm taxi has one cel only. The 2 writes to `$07F8` (taxi sprite ptr) -- are they per-level (color swap) or per-frame? Read those sites | `stRender.c`: reduce `ST_TAXI_CEL_COUNT` to 1, kill `thrustFrame` | +| 2.2 | What renders the engine flame under the taxi during thrust? Either a second sprite or a tile overwrite | `stRender.c`: flame overlay | +| 2.3 | Passenger walk: trace updates to passenger sprite Y/X in the sprite state table (NOT `$D004-$D00D`; the table memory) | `stPassenger.c`: walk frame timing | +| 2.4 | Title-screen layout: the C64 title positions logo / credits / instructions at specific char rows. The extractor pulls the screen layout; check the .txt against the disassembly's title-init routine to confirm we render the same layout | `assets/levels/title.txt`, `stRender.c` title overlay | + +**Checkpoint**: the title screen, gameplay, and game-over screens +each look like the C64. + +## Phase 3 -- audio + +Authentic feel needs the SID engine. JoeyLib audio is per-platform, +so the abstraction is "SFX event id" -- the SID details only land in +the DOS / Amiga / ST tone-generator backend. + +| Task | Investigate | Update | +| ---- | ------------------------------------------ | ------ | +| 3.1 | Music engine: voice 1+2 melodic table. Find where tunes are stored, how the row pointer advances, how note-to-freq lookup works | `stAudio.c`: music playback state machine | +| 3.2 | Thrust SFX: confirmed voice 1 freq sweep from `$721B`. Find writer of `$721B` (start of thrust) and what triggers it to stop | `stAudioSfxThrust` envelope | +| 3.3 | Crash SFX: voice 3 noise burst. `LDA #$81 STA $D412 ... LDA #$80`. Find duration / pitch envelope | `stAudioSfxCrash` | +| 3.4 | Land SFX: a successful landing must trigger some audible cue. Trace from collision dispatch when sprite-bg collision is at low vy | `stAudioSfxLand` | +| 3.5 | Door / passenger SFX: passenger enters/exits the cab on a pad. Probably also voice 3 noise or a brief tone | new SFX | + +**Checkpoint**: thrust, land, crash, and at least one music loop +all play audibly on DOS. Approximate timbres OK; pitch + envelope +should roughly match. + +## Phase 4 -- game logic + +Make the game progress. State machine, scoring, lives. + +| Task | Investigate | Update | +| ---- | ---------------------------------------- | ------ | +| 4.1 | Passenger spawn: when does fare N+1 appear after fare N is delivered? Look for writes to the passenger-active flags in the state table | `stPassenger.c` | +| 4.2 | Patience timer: a passenger has a countdown. Find the per-frame decrement and what happens at zero (passenger leaves? lose pad?) | `stPassenger.c` | +| 4.3 | Scoring: base fare from `$7D...`, tip math for time-on-board. BCD arithmetic on a 4-byte score | `stHud.c`, score storage | +| 4.4 | Level transitions: `$7215 == $19` is the "level complete" sentinel. What writes that, and how does the game pick the next level? | `spacetaxi.c` main switch | +| 4.5 | Lives: where is the lives counter? How does the game decide game-over? | `stEngine.c` crash branch, `spacetaxi.c` | + +**Checkpoint**: can complete a level, advance, lose all lives, +see game-over, restart from title. + +## Phase 5 -- level data + +Match the original's 25 levels (or however many ship in the disk +image; verify from `SPACETAX.D64`). + +| Task | Investigate | Update | +| ---- | ---------------------------------------- | ------ | +| 5.1 | Level table layout in RAM: where are per-level pad coords stored? Per-level music ID? Per-level taxi spawn? Per-level fare list? | `stLevel.c` STL1 format | +| 5.2 | Extract every level from the D64 image (we already have level1 / level2 / title dumps; need a way to dump every level state). Either replay through the game in VICE and snapshot each level, or read the level table once and reconstruct | extractor: bulk-export | +| 5.3 | Verify the title screen extraction matches the original byte-for-byte (chars + colors) | `title.dat` regenerate | + +**Checkpoint**: all original levels playable, named correctly, +ordered correctly. + +## Phase 6 -- cross-platform parity + +DOS lands first. Then bring the other three up. + +| Target | Specific work | +| ------ | ------------------------------------------------------------ | +| Amiga | Verify build still runs; PT replayer for music; joystick | +| Atari ST | Same; YM2149 for SFX, MOD for music | +| IIgs | Resolve ROOT-segment limit so the binary fits; wire asset | +| | pipeline into the disk packager (3 options in earlier note); | +| | Ensoniq sound match | + +**Checkpoint**: each of the four targets boots, shows title, +plays at least one level end-to-end. + +## Working notes + +- All chat-side analysis must stay in plain English -- disassembly + excerpts go in local files (`MECHANICS.md`, `labels.txt`), not + pasted back into the session. +- One subsystem per session is the right cadence. Trying to "do + them all at once" without user-test feedback compounds errors. +- Keep this plan up to date: as each task lands, mark it DONE + with the commit SHA or date, and add new tasks discovered + during the work. diff --git a/VERIFIED.md b/VERIFIED.md new file mode 100644 index 0000000..030ec15 --- /dev/null +++ b/VERIFIED.md @@ -0,0 +1,96 @@ +# Space Taxi disassembly: verified claims + +Each row below references a claim in `MECHANICS.md` and the trace +that verifies it. Trace scripts live in `stuff/spacetaxi/trace.py` +plus the inline scripts run during verification sessions. + +## VERIFIED via emulator trace + +| Claim | Evidence | +| ----- | -------- | +| `$4354` BCD-add with blob `$43B9` modifies HUD position 3 with digit '5' | Run `$4354` against all-blank `$07C2`. Result: `$07C2-$07C8 = $66 $66 $66 $6F $66 $74 $74`. Only one write to `$07C5`. | +| Fare blob `$43C1` = +95 (positions 2,3) | Same harness, different blob. Result has digits '9' at pos 2, '5' at pos 3. | +| Fare blob `$43C9` = +50 (position 2) | Same harness. Result has digit '5' at pos 2. | +| Stage 6 (`$6742`) writes only to `$07C2`, never `$07E0` | Memory-write trace shows the only HUD writes target `$07C5`. | +| `$07E0` has no in-game writers except `$43AA` (hudInit template copy) | grep across `live-raw.lst` finds only `$43AA` and `$635F` (sceneLoad restore). | +| `$6032` physics: X-accel suppressed when `$7197 & 1 == 1` via `$6190` | Run physics with RIGHT-held and `$7197 = $C1`: `ax = 0`. With `$7197 = $C0`: `ax = +14`. | +| `$6032` physics: Y-accel NOT gated by parity | Same test with UP/DOWN held + various `$7197`: Y accel always = +/-yAccel regardless of parity. | +| Parity bit `$7197 & 1` toggle sites are death-phase-1 (`$6A93/95`) and FIRE-button rising edge (`$63FF/$6401`) | grep `EOR #$01 ... STA $7197` across all listings -- no other writers. | +| `$619B` (gated dispatch) preserves the parity bit | Code at `$619B-$61BC`: ORA with captured parity `$61` before STA `$7197`. | +| `$645C` pad detection requires EXACT row match | At pad-table row 204 with cab at (115, 204): `$7150 = 0 -> 1`. At row 203 (1 off): stays 0. | +| `$645C` pad detection X-bounds work | At (100, 204): stays 0 (X<110). At (208, 204): stays 0 (X>198). At (115, 204): `$7150 -> 1`. | +| `$6966` collision dispatch: bg-collision triggers DEATH | With `$D01F & 1 = 1`, phase advances 0->1, sprite-0 cel $C1->$CC. | +| `$6966`: sprite-0 + passenger collision (bits 0+3 in `$D01E`) ALSO triggers death (in raw.bin state) | With `$D01E = $09`: phase->1, `$721D=1` (trampoline RTS preserved A=1). | +| `$6966`: sprite-sprite without sprite-0 bit does NOT trigger death | With `$D01E = $08` (only bit 3): phase stays 0. | +| `$7D75` trampoline is STATIC in raw.bin and level1.bin | Direct read: bytes `$4C $9A $7D` in both dumps. No writers anywhere in the code. | +| `$7D75` trampoline returns A unchanged in default state | Patched the operand to a `LDA #$00; RTS` stub: passenger collision no longer triggers death (phase stays 0). With default RTS: phase advances. | +| `$65C1` death-stage dispatch JMP table verified for stages 1-9 | Instrumented run of `$65C1` with each `$7163` value; first JMP captured matches the documented target ($665F, $66B7, $66DA, $66DD, $6739, $6742, $67A3, $67A6, $67C6). Stage 0 is fall-through (no JMP). | +| Per-level X-accel and Y-accel can DIFFER | Levels C, D, E, F, G, K, M, N, T, V have `$7D91 != $7D8F` after decompress. | +| Per-level X-gravity is non-zero on levels P and U | Levels P (`$7D95 = $04`) and U (`$7D95 = $02`); all others = 0. | +| Level K is anti-gravity (`$7D93 = $F9`) | After 10 ticks of physics with no input, cab Y position DECREASES (moves up), vy = -70. | +| Level P side-wind drifts cab right at $4/frame | After 20 ticks: vx = +80, x position drifted +840 sub-units. | +| `.dat` files preserve all per-level fields | Parse STL2 header for all 24 levels; xAccel/yAccel/xGrav/yGrav/border/bg match emulator extraction byte-for-byte. | +| `$CC6C` music tick is a NO-OP when track pointers are zero | Run with `$CB80/$CB81 = $00`, X = 0/7/14: 4 instructions executed, 0 SID writes. Confirms "gameplay is silent" because no tracks are loaded. | +| In post-decompress state for level A, ALL THREE voice track pointers are zero | Direct read of `$CB80/81`, `$CB87/88`, `$CB8E/8F` after `run_scene(0)`: all `$00 $00`. So even if the IRQ runs (which my emulator can't simulate due to the KERNAL `$EA31` exit being absent), there's nothing for it to play. Gameplay is silent. | +| `$5FBB` draws "GAME OVER!" to row 11 of screen RAM | Direct trace of `$5FBB` writes the chars `$47 $41 $4D $45 $20 $4F $56 $45 $52 $21` to $0400+11*40+15 through $0400+11*40+24 -- byte values matching the ASCII string "GAME OVER!" (using the game's custom charset at `$2800`). | +| `$5FBB` is called via `$6BD0` after every death-fall (NOT just final game-over) | Only caller of `$6BD0` is `$5C29` (end of `$5BD1+` template-restore path). NOT called from every death. CORRECTION: `$5C29` is at the end of `$5BD1`'s fall-through path (reached only when `$5CF1 >= 3`). | +| `$5CF1` is a 4-phase animation state machine (`$5BBB` dispatcher) | Verified by tracing all `$5CF1` writers: `$5B2F` sets to 0; `$5C6F` `$5C9C` `$5CDF` each INC at the end of phase 0/1/2 finalizers. So a fare sequence cycles 0->1->2->3. | +| `$5C2C` is the **drop-off animation** ($5CF1=0), not death-fall | Verified by emulator trace at row $D3 -> 642 instructions -> cab at $D4, BCD-add (+50 via $43C9) and hudInit called. Routine RTS-es cleanly, does NOT JMP to $6BD0 from here. | +| `$5B07` is "game-over reset", NOT "deathTuneSetup" | The `JSR $6283 with A=$24` decompresses **scene 24 (title screen)**, not a song. My earlier "song 24" label was wrong: `$6283` is the bank-switch wrapper around `$9656` (the scene decompressor), and `$24` is the scene index. Verified by checking the scene pointer table at `$9600` -- scene $24 is the title. | +| `$71CE` (fares done) INCs ONLY at `$6BD6` | grep finds only one INC (`$6BD6`) and one STA (`$5EE9 = 0` at game init). | +| `$7213` (fare target) is player-selectable 1..4 at title | Writers: `$48AF` sets to 1 (default); `$5310-$5328` is a title-screen joystick UI -- RIGHT INCs, LEFT DECs, ANDed with `#$03` to clamp 0..3 (= 1..4 with +1 baseline). | +| Game-end goes to `$5EC4` (gameInitContinue, restarts to title) | `$6BE1: JMP $602F`, `$602F: JMP $5EC4`. So Space Taxi's "level complete" just resets the game -- no inter-level progression in the original. | +| `romToLevel.py` bug: levels A/O/R had fareCount=0 (uncompletable) | Verified by parsing all 24 .dat files. Fixed: single-pad levels now emit `(0, 0)` self-loop fare. | +| Full `$5F40` main-loop body verified: 25 JSRs in exact order matching MECHANICS.md | With stubs for KERNAL `$FFE1`, `$$404B` (vblWait), `$44EF` (waitTickSpin), `$44D2` (waitVoicesIdle), and `$7D65 = $80` so `$6BE7` doesn't early-exit: 716 instructions, 25 depth-1 JSRs captured in documented order. | +| `$4BF8` (CIA1 DDR setup) is called every main-loop iteration, not "once" | Verified by trace: appears in the depth-1 chain at PC `$5FB5`. Setting CIA DDRs each frame is wasteful but harmless. | +| Emulator now covers all opcodes used by the main-loop body | Added INC/DEC absx/zpx, ASL/LSR/ROL/ROR zpx/absx, LDA/STA/AND/ORA/EOR/ADC/SBC/CMP indx, CPX/CPY zp. The main loop runs to completion through these instructions. | +| Passenger-spawn flow `$660B` advances state | Verified by tracing `$660B` with `$71CC = 1`: `$715C` 0->1 (fare slot incremented), `$7163` 0->1 (death stage advances to "passenger appearing"), `$7176` (sprite-1 passenger X) changes. JSR chain shows `$4080` (RNG) and `$6537` (padHoverSetup) called. | +| Flame is **sprite 2**, NOT sprite 1 (correction) | `$6D6A` flameSpriteUpdate writes `$7177` (= `$7175` + 2 = sprite 2 X) and `$7199` (sprite-2 ptr). Sprite 1 (`$7176`, `$7198`) is the active passenger sprite, set by `$660B` and other passenger-spawn paths. Earlier MECHANICS.md sprite-role table was wrong. | +| `$619B` cab cel selection: LEFT/RIGHT only writes when held, otherwise preserves $7197 | 7 input/parity combos tested: LEFT held -> base $DC (or $DD with parity); RIGHT held only when NO LEFT -> base $C0 (or $C1); no L/R held -> $7197 unchanged. Default-RIGHT facing. | +| Pad table byte 5/6 are PASSENGER spawn coords (not "anchor X" as earlier) | `$662E: LDA $7D0F,X -> $7186` (passenger X-frac); `$6634: LDA $7D10,X -> $7176` (passenger X col). My "second X for cab-stop snap" reading was wrong; these are the spawn position for the passenger sprite on the pad. | +| Pad table byte 7 has NO runtime readers | `grep -nE "11 7D"` finds zero readers in the live disassembly. The `$C2/$C4` observed values are dead. My earlier "ground vs elevated pad style" interpretation was speculation the disassembly doesn't support. | +| Stage 5 `$6739` is pad-color-alt + zero-death-flag | Verified by direct call: 11 instructions. `$7167` stays 0. `$DBDC: $FF -> $0B` and `$DBB4: $D9 -> $07` -- color RAM writes via JSR $6877 (padColor_passOff). | +| Custom charset at `$2800` is ASCII-compatible | Decoded the 8x8 bitmap glyphs for `$47/$41/$4D/$45/$4F/$56/$52/$21`: each renders as the corresponding Latin letter / '!'. So bytes spelling "GAME OVER!" in ASCII really do show up as readable "GAME OVER!" on screen. | +| Level K extended physics: cab rises ~50 px in 60 ticks no input | Y position: `$A000 -> $6DF6` over 60 ticks. Net -12810 sub-units = -50 px (moves UP, anti-gravity confirmed at scale). Expected per gravity-integration math: -12390. The 420-unit discrepancy = exactly one frame's vy of -420 (integration-order detail; vy applied before update on first tick). | +| `$4253` sprite-shadow marshal: writes X,Y interleaved to `$71A7-$71B6` | Tested with per-sprite X = `$A0..$A7`, Y = `$50..$57`. Result at `$71A7` = `A0 50 A1 51 A2 52 ... A7 57`. Matches MECHANICS.md format. | +| `$4293` sprite-shadow flush: writes 5 register blocks to VIC | Verified per-register: `$D000-$D00F = $10-$1F` (pos), `$07F8-$07FF = $D0-$D7` (ptrs), `$D027-$D02E = $E0-$E7` (colors), `$D010 = $55` (X-MSB), `$D015 = $FF` (enable). | +| `$42E9` SFX-load takes a **9-byte** program, not 7 | Verified with 9-byte program: byte 8 is voice index (read FIRST), bytes 0-6 written to `$D400+vi*7..+6`, byte 7 stored to `$7216+vi` (release timer), byte 4 ALSO stored to `$7218+vi` (ctrl mask). Earlier MECHANICS.md "7-byte program, voice index then 7 bytes" was wrong. | +| `$4080` RNG with X=100 produces non-uniform distribution | 100 trials: all in [0..99], mean ~61, ~16-cycle repeat pattern. Biased toward higher values. C64 raster-register based, not uniform. | +| `$63DD` fireButtonEdge fires SFX only on rising edge | 4 cases tested: on-pad RTS; airborne+no-fire RTS; airborne+fire-held no-op; airborne+fire-rising = 8 SID writes ($42E9 SFX load) + `$7197` parity toggled + `$71BD` latch set. | +| `$4FCB` runStopWatcher branches verified | idle (no key): 12-step RTS. RUN/STOP key ($028D bit 0): kills SID voices + `$7222 = $80`. post-mortem flag: 6-step early RTS. game-mode flag: enters wait loop at `$5006` checking joystick. | +| Stage 1 `$665F` death branch: `$7163 1->8`, DEC `$715C` | Verified: with `$7164=1`, walked `$7198` down to `$C7`, `$7163` went 1->8 (jumps to stage 8), `$715C` decremented (5->4). | +| Stage 6 `$6742` scoring: `$07C2 += 5` and `$7163 6->7` | After 2 ticks with `$7198 $C8 -> $C7`: HUD `$07C2 = $66 $66 $66 $6F $77 $74 $74` (= "___5. " = +5 at pos 3), `$7163 6 -> 7`. Score confirmed +5 per fare. | +| `$6F18` takeoffSetup gates on `$7215 >= 2` | `$7215 < 2`: 7-step early RTS, zeros `$7163`. `$7215 = 2`: 304 steps, sets `$7163 = 5` (takeoff stage), `$7D8E = 1`, `$716D = 1`, `$71CD = 1`. | +| `$6888` taxiSpawnInit: copies `$7D5B-$7D5F` -> taxi pos, zeros vels, sets cel | Per-level spawn at `$7D5B-$7D5F` (`$80 $40 $00 $30 $60`) copied to `$7D61-$7D65`. Velocities cleared. `$7197 = $C0` (default cab cel). `$71C7 = $7D60 = $AA` (fuel/max copy). | +| Animation main loop at `$5B85` is DEAD CODE in gameplay | Original .prg has `JMP $5B85` at `$FCB5` (per full-disasm.lst). raw.bin (live runtime) has `$08 $AB $28` at `$FCB5` -- DIFFERENT bytes, meaning runtime patched/overwrote that JMP. So the `$5B07-$5BBA` animation main loop was load-time / startup code that got replaced. NEVER reached in active gameplay. | +| Custom charset is ASCII-compatible | Decoded the 8x8 bitmap glyphs at `$2800 + char*8` for "GAME OVER!": each renders as recognizable Latin letter. ASCII text in screen RAM displays correctly. | +| `$704E` hudArchive copies 7 bytes from `$07C2/$07E0` to `$71D3,X*8` / `$71F3,X*8` | With `$7214 = 2` (fare idx -> X = 16), `$07C2 = AA BB CC DD EE FF 11` and `$07E0 = 22 33 44 55 66 77 88`: after call, `$71E3 = AA BB CC DD EE FF 11 00` and `$7203 = 22 33 44 55 66 77 88 00`. Per-fare 8-byte slot stride confirmed; only 7 bytes written, 8th stays at prior value. | +| `$43A5` hudInit copies template `$43B1-$43B7 -> $07E0-$07E6` | After clobbering `$07E0` with `$FF * 7`, calling `$43A5`: `$07E0 = $66 $66 $66 $74 $77 $74 $74` matching template byte-for-byte. | + +## UNVERIFIED (acknowledged, not yet proven) + +| Claim | Why unverified | +| ----- | -------------- | +| `$715C` is "fare slot count" not lives | INC at `$6559`, DEC at `$6B7B` etc., scene-load zeroes it. Semantics still unclear; my "fare slot" guess hasn't been confirmed against gameplay observation. | +| Bonus score blobs `$43C1` (+95) and `$43C9` (+50) fire on specific gameplay events | The call sites (`$5C82`, `$5D03`) are in the `$5BBB`-dispatched anim and a per-passenger-counter decrement; the gameplay scenarios that put `$5CF1` and `$71CF,X` in the necessary states haven't been replayed in the emulator. | +| In-cab score `$07C2` ever gets reset / committed to a persistent score | Only writers are BCD-add (during stage 6) and sceneLoad restore (from `$71D3` backup). The accumulation logic is in the backup tables, not a global score. The port simplifies this to a `uint32_t game->score`. | +| Passenger pickup mechanism in the C64 | The RTS-without-death path at `$69B3` requires `$71CA & 2` (sprite-1 bit) ALSO set, which is a 3-way sprite collision. Not yet reproduced. | +| `$7D75` trampoline ever gets patched at runtime | No writers found via grep. The "patches on pickup" theory was wrong. Possibly the trampoline is set ONCE at game-load via the bank-switched code at `$9656` (under-ROM), which my emulator can run but I haven't watched specifically for `$7D76/$7D77` writes during decompress. | +| Initial parity bit value during normal gameplay | The state on level entry isn't traced; depends on what code sets `$7197` between title and gameplay. | + +## TRACE INFRASTRUCTURE + +- `stuff/spacetaxi/trace.py`: instrumented wrapper around `cpu6502.Cpu6502`. `Tracer.from_dump('raw.bin').call(addr)` runs a subroutine and snapshots memory + JSR chain. +- `stuff/spacetaxi/cpu6502.py`: minimal 6502 emulator. Sufficient for all routines tested above; throws `NotImplementedError` for unsupported opcodes (none hit so far in gameplay paths). +- `stuff/spacetaxi/dumpScenes.py` + `dumpAllLevels.py`: pre-existing scene-decompress drivers. +- `stuff/spacetaxi/romToLevel.py`: emits the `.dat` files from raw.bin via the same emulator. + +## METHODOLOGY (for future verification sessions) + +For each new claim I want to commit to MECHANICS.md: + +1. **Set up the pre-state explicitly.** Don't assume registers/memory — `poke_byte` everything that affects the routine's behavior, plus document why each pre-state value matters. +2. **Call the routine via `Tracer.call(addr)`.** Catch failures (NotImplementedError, runaway loops) and reduce scope if it doesn't return cleanly. +3. **Diff memory.** Use `tracer.memory_writes()` to see exactly what changed. Don't trust label names. +4. **Verify expected branches taken.** Use `instr_log = True` for routines where control flow matters. +5. **Document the trace command in this file** so the claim can be re-verified later. diff --git a/assets/font.png b/assets/font.png new file mode 100644 index 0000000..6a55e57 Binary files /dev/null and b/assets/font.png differ diff --git a/assets/genPlaceholderArt.py b/assets/genPlaceholderArt.py new file mode 100644 index 0000000..35fc635 --- /dev/null +++ b/assets/genPlaceholderArt.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# genPlaceholderArt.py -- generate generic geometric placeholder art +# for the Space Taxi port. Output is 320x200 indexed PNG, 16-color. +# Replace these with hand-authored art any time; the build pipeline +# picks up whichever PNG sits in assets/. + +import os +from PIL import Image, ImageDraw + +ROOT = os.path.dirname(os.path.abspath(__file__)) + +# 16-color palette matching the engine's kDefaultPalette (see stRender.c). +# Each entry is (R, G, B) in 0..255. The .jas format will store the +# 4-bit-per-channel quantization automatically via joeyasset. +PALETTE_RGB = [ + (0x00, 0x00, 0x00), # 0 black + (0x00, 0x00, 0x77), # 1 dark blue + (0xCC, 0x00, 0x00), # 2 red + (0x00, 0x77, 0x00), # 3 dark green + (0x66, 0x44, 0x00), # 4 brown + (0xEE, 0xEE, 0x00), # 5 yellow + (0x88, 0x88, 0x88), # 6 mid gray + (0xBB, 0xBB, 0xBB), # 7 light gray + (0x44, 0x66, 0xFF), # 8 sky blue + (0x77, 0x88, 0xFF), # 9 light blue + (0xFF, 0x88, 0x88), # 10 light red + (0x88, 0xFF, 0x44), # 11 light green + (0xFF, 0x88, 0x00), # 12 orange + (0xFF, 0x00, 0xFF), # 13 magenta + (0x00, 0xFF, 0xFF), # 14 cyan + (0xFF, 0xFF, 0xFF), # 15 white +] + + +def new_canvas(): + """Returns a fresh 320x200 indexed PIL image with the palette installed.""" + img = Image.new('P', (320, 200), 0) + flat = [] + for r, g, b in PALETTE_RGB: + flat.extend([r, g, b]) + flat.extend([0] * (768 - len(flat))) # pad to 256 entries + img.putpalette(flat) + return img + + +def write_indexed_png(img, path): + """Save with mode='P' so PNG output is indexed (joeyasset wants this + when converted through ppm; ImageMagick's `convert` preserves + indexed mode when writing PPM).""" + img.save(path, 'PNG', optimize=True) + print(f" wrote {path}") + + +def gen_tilebank(): + """tilebank0.png: 320x200, 40x25 cells of 8x8 each (= 1000 tile + slots). The engine convention is: + index 0 = empty (non-solid) + index 1..63 = solid walls + index 64..127 = landing-pad surfaces + index 128..255 = decorative non-solid + We fill a few representative slots; the rest stay black so they + show as "empty" if a level references them. + """ + img = new_canvas() + px = img.load() + + def draw_tile(idx, painter): + bx = (idx % 40) * 8 + by = (idx // 40) * 8 + painter(bx, by) + + def block(color): + return lambda x, y: [px.__setitem__((x+dx, y+dy), color) + for dx in range(8) for dy in range(8)] + + def bricks(color_a, color_b): + def paint(x, y): + for dy in range(8): + row_offset = 4 if (y // 8 + dy // 4) % 2 == 0 else 0 + for dx in range(8): + is_grout = (dx == (row_offset % 8)) or (dy == 3 or dy == 7) + px[x + dx, y + dy] = color_b if is_grout else color_a + return paint + + def stripes(color_a, color_b): + def paint(x, y): + for dy in range(8): + c = color_a if (dy + (x // 8)) % 2 == 0 else color_b + for dx in range(8): + px[x + dx, y + dy] = c + return paint + + def landing_pad(color_a, color_b): + # diagonal stripes signal a landing-pad surface + def paint(x, y): + for dy in range(8): + for dx in range(8): + px[x + dx, y + dy] = color_a if ((dx + dy) // 2) % 2 == 0 else color_b + return paint + + # tile 0 -> already black (empty); leave as-is + + # walls 1..7: a variety of brick/stripe patterns + draw_tile(1, bricks(6, 0)) # gray brick + draw_tile(2, bricks(4, 0)) # brown brick + draw_tile(3, block(6)) # solid gray + draw_tile(4, block(4)) # solid brown + draw_tile(5, stripes(6, 7)) # gray stripes (ceiling) + draw_tile(6, stripes(1, 8)) # blue stripes (sky deco) + draw_tile(7, block(7)) # solid light gray + + # landing pad tiles 64..67 + draw_tile(64, landing_pad(5, 12)) # yellow/orange pad + draw_tile(65, landing_pad(11, 3)) # green pad + draw_tile(66, landing_pad(9, 8)) # blue pad + draw_tile(67, landing_pad(15, 7)) # white pad + + write_indexed_png(img, os.path.join(ROOT, 'tiles', 'tilebank0.png')) + + +def gen_sprites(): + """sprites.png: 320x200, layout: + y= 0..23 : taxi cels (4 frames of 24x24 starting at x=0) + y= 24..39 : passenger walk cels (4 frames of 16x16 at x=0) + Remaining rows free for future content. + """ + img = new_canvas() + d = ImageDraw.Draw(img) + + # ---- Taxi cels: simple side-view "pod with thruster" ---- + # Cel 0 = idle (thruster off) + # Cel 1..3 = thrust-on, alternating flame frames + for cel in range(4): + x = cel * 24 + # Pod body: rounded rectangle in gray + d.rectangle([x+2, 4, x+21, 16], fill=7, outline=15) + # Cockpit window in blue + d.rectangle([x+13, 6, x+19, 10], fill=8, outline=15) + # Landing skid + d.line([(x+3, 17), (x+20, 17)], fill=6) + d.line([(x+3, 17), (x+3, 19)], fill=6) + d.line([(x+20,17), (x+20, 19)], fill=6) + # Thruster flame (cels 1..3 only, alternating shape) + if cel >= 1: + flame_color = [2, 12, 5][(cel - 1) % 3] + flame_top = 18 + flame_bot = 22 if cel != 2 else 23 + d.polygon([(x+8, flame_top), + (x+15, flame_top), + (x+12, flame_bot)], fill=flame_color) + + # ---- Passenger cels: small stick figure, walk cycle ---- + # Centered in 16x16, walking right. + for cel in range(4): + x = cel * 16 + y = 24 + # Head + d.ellipse([x+5, y+1, x+10, y+6], fill=10, outline=15) + # Body + d.line([(x+7, y+7), (x+7, y+12)], fill=15) + # Arms (swing alternately based on cel) + if cel == 0 or cel == 2: + d.line([(x+4, y+10), (x+10, y+10)], fill=15) + else: + d.line([(x+3, y+9), (x+11, y+11)], fill=15) + # Legs (alternating stride) + if cel == 0 or cel == 2: + d.line([(x+7, y+12), (x+4, y+15)], fill=15) + d.line([(x+7, y+12), (x+10,y+15)], fill=15) + else: + d.line([(x+7, y+12), (x+5, y+15)], fill=15) + d.line([(x+7, y+12), (x+9, y+15)], fill=15) + + write_indexed_png(img, os.path.join(ROOT, 'sprites', 'sprites.png')) + + +def gen_font(): + """font.png: 320x200 (40x25 grid of 8x8 ASCII glyphs). + Authored manually for the printable subset (0x20..0x7E). + Each glyph cell is at (ascii%40, ascii/40) when ascii<128. + Unfilled cells stay black (treated as TILE_NO_GLYPH at runtime). + """ + # A compact 5-pixel-wide bitmap font, drawn in 7 rows with 1px + # padding on all sides (so 5x7 active inside an 8x8 cell, baseline + # at row 6). The font supports digits, uppercase letters, space, + # and basic punctuation -- enough for HUD text. Lowercase letters + # map to the same glyphs as uppercase (room to add later). + GLYPHS = { + ' ': [], + '!': ['..#..', '..#..', '..#..', '..#..', '.....', '..#..', '.....'], + '"': ['.#.#.', '.#.#.', '.....', '.....', '.....', '.....', '.....'], + '0': ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'], + '1': ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'], + '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'], + '3': ['.###.', '#...#', '....#', '..##.', '....#', '#...#', '.###.'], + '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'], + '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'], + '6': ['.###.', '#....', '#....', '####.', '#...#', '#...#', '.###.'], + '7': ['#####', '....#', '...#.', '..#..', '..#..', '..#..', '..#..'], + '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'], + '9': ['.###.', '#...#', '#...#', '.####', '....#', '....#', '.###.'], + ':': ['.....', '..#..', '.....', '.....', '.....', '..#..', '.....'], + '.': ['.....', '.....', '.....', '.....', '.....', '..#..', '.....'], + '-': ['.....', '.....', '.....', '#####', '.....', '.....', '.....'], + '/': ['....#', '...#.', '...#.', '..#..', '.#...', '.#...', '#....'], + 'A': ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'], + 'B': ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'], + 'C': ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'], + 'D': ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'], + 'E': ['#####', '#....', '#....', '####.', '#....', '#....', '#####'], + 'F': ['#####', '#....', '#....', '####.', '#....', '#....', '#....'], + 'G': ['.###.', '#...#', '#....', '#..##', '#...#', '#...#', '.###.'], + 'H': ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'], + 'I': ['.###.', '..#..', '..#..', '..#..', '..#..', '..#..', '.###.'], + 'J': ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'], + 'K': ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'], + 'L': ['#....', '#....', '#....', '#....', '#....', '#....', '#####'], + 'M': ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'], + 'N': ['#...#', '##..#', '#.#.#', '#.#.#', '#..##', '#...#', '#...#'], + 'O': ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'], + 'P': ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'], + 'Q': ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'], + 'R': ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'], + 'S': ['.###.', '#...#', '#....', '.###.', '....#', '#...#', '.###.'], + 'T': ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'], + 'U': ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'], + 'V': ['#...#', '#...#', '#...#', '#...#', '.#.#.', '.#.#.', '..#..'], + 'W': ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'], + 'X': ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'], + 'Y': ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'], + 'Z': ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'], + } + + img = new_canvas() + px = img.load() + + def paint_glyph(ascii_code, glyph_rows): + col = ascii_code % 40 + row = ascii_code // 40 + bx = col * 8 + by = row * 8 + # active 5x7 area: cell-x 1..5, cell-y 0..6 (top row, 1px left pad) + for ry, line in enumerate(glyph_rows): + for rx, ch in enumerate(line): + if ch == '#': + px[bx + 1 + rx, by + ry] = 15 # white + + for ascii_code in range(32, 127): + c = chr(ascii_code) + if c in GLYPHS: + paint_glyph(ascii_code, GLYPHS[c]) + elif 'a' <= c <= 'z': + paint_glyph(ascii_code, GLYPHS[c.upper()]) + + write_indexed_png(img, os.path.join(ROOT, 'font.png')) + + +def main(): + os.makedirs(os.path.join(ROOT, 'tiles'), exist_ok=True) + os.makedirs(os.path.join(ROOT, 'sprites'), exist_ok=True) + print("genPlaceholderArt: emitting 320x200 indexed PNGs...") + gen_tilebank() + gen_sprites() + gen_font() + print("done.") + + +if __name__ == '__main__': + main() diff --git a/assets/levels/format.md b/assets/levels/format.md new file mode 100644 index 0000000..a883d27 --- /dev/null +++ b/assets/levels/format.md @@ -0,0 +1,92 @@ +# Space Taxi level .dat format (STL2) + +A small custom binary format that `stLevelLoad()` reads at scene +boot. Output files land in `examples/spacetaxi/generated/levels/` +(from `romToLevel.py` for the 24 canonical C64 levels, or from +`mkstlevel` for hand-authored levels in `assets/levels/*.txt`). The +build copies them under `DATA/levels/levelNN.dat` in the runtime +asset bundle. All multi-byte fields are little-endian. No +compression; ~2 KB per level. + +| Offset | Type | Field | +|-------:|:----------|:-----------------------------------------------| +| 0 | 4 bytes | magic `S T L 2` | +| 4 | u8 | nameLen (0..23) | +| 5 | char[N] | name (no NUL; max 23 chars) | +| 5+N | u8 | tileBankId | +| ... | u8 | musicId (UNUSED -- C64 gameplay is silent; see VERIFIED.md) | +| ... | u8 | bgColor (palette slot) | +| ... | u8 | borderColor (palette slot, also HUD-band fill) | +| ... | u8 | taxiSpawnTileX | +| ... | u8 | taxiSpawnTileY | +| ... | u8 | xAccel (horizontal thrust magnitude / frame) | +| ... | u8 | yAccel (vertical thrust magnitude / frame) | +| ... | i8 | xGrav (constant horizontal accel; side-wind on levels P, U) | +| ... | i8 | yGrav (constant vertical accel; anti-gravity on level K = -7) | +| ... | u8 | bgColor1 ($D022, VIC multicolor -- UNUSED) | +| ... | u8 | bgColor2 ($D023, VIC multicolor -- UNUSED) | +| ... | u8 | bgColor3 ($D024, VIC multicolor -- UNUSED) | +| ... | u8 | spriteMc0 ($D025, sprite multicolor -- UNUSED) | +| ... | u8 | spriteMc1 ($D026, sprite multicolor -- UNUSED) | +| ... | u8 | sprite0Color ($D027, cab fallback color) | +| ... | u8 | sprite1Color ($D028, flame fallback color) | +| ... | u8 | padCount (0..10) | +| ... | pad[] | padCount * 4 bytes: letter, tileX, tileY, tileW | +| ... | u8 | fareCount (0..16) | +| ... | fare[] | fareCount * 2 bytes: spawnPad, destPad | +| ... | u8[40*25] | tilemap (row-major, full 25 rows) | +| ... | u8[40*25] | colormap (row-major, palette slot per cell) | + +Notes: + +- `xAccel/yAccel/xGrav/yGrav` mirror the C64 per-level templates at + `$7D8F-$7D96`. Side-wind levels P (`xGrav = +4`) and U (`+2`); the + anti-gravity level K (`yGrav = -7`) makes the cab drift upward + without input. See `VERIFIED.md` for the emulator-traced extracts. +- `musicId` is preserved for format symmetry only; the C64 original + has no gameplay music (only title/score-screen jingles). +- The five VIC multicolor fields are preserved so the .dat is a + faithful byte-for-byte capture of the C64 `$7D00-$7D08` block. The + port renders in single-color mode and ignores them at runtime. +- Pads are 4 bytes (letter + 3 tile coords); there is no patience + byte on fares -- Space Taxi proper has no patience timeout, and the + emulator trace at `$71CE/$7213` confirmed the only gating is the + player-selectable fare target at the title screen. + +## Tile-index conventions (in the tilemap byte) + +Indexes 0..255 reference the level's active tile bank. Reserved +ranges: + +| Range | Meaning | +|----------|---------------------------------------------------------| +| 0 | empty (sky / interior space; non-solid) | +| 1..63 | solid (walls, ceilings, support structures) | +| 64..127 | landing-pad surfaces (also solid; pad collisions live here) | +| 128..255 | decorative non-solid (lights, signs, animation frames) | + +The engine's `isSolidAt()` uses this convention. If you redesign a +tile bank, keep the indexing consistent so the physics keeps working +without changes. + +## Authoring pipeline + +1. Author a 256-tile (or smaller) tile sheet as an indexed PNG. Each + tile is 8x8 px. Arrange in a grid; the bank loader assumes row- + major, tile-index = `ty * tilesPerRow + tx`. Drop the PNG at + `examples/spacetaxi/assets/tiles/tbank.png`. +2. The Makefile bakes per-target via `tools/assetbake/assetbake.py + --type tile --target tbank.png tbank.tbk`. Output + lands in `examples/spacetaxi/generated//tiles/` and is + staged into the runtime tree at `build//.../DATA/tiles/`. +3. Author each level layout in a text editor or grid tool, save as a + .dat per this spec. The helper `examples/spacetaxi/mkstlevel` + converts from a human-readable text grid to .dat. The 24 canonical + C64 levels are emitted by `stuff/spacetaxi/romToLevel.py` directly + from a raw VICE dump of the original .prg. + +## Naming + +Use the original game's level names as the `name` field (uppercase +ASCII, max 23 chars). The HUD displays this at the bottom-right of +the screen. diff --git a/assets/levels/title.txt b/assets/levels/title.txt new file mode 100644 index 0000000..4d80f7b --- /dev/null +++ b/assets/levels/title.txt @@ -0,0 +1,142 @@ +# Extracted from VICE memory dump. Edit at your own risk; +# regenerating will overwrite. Keep the @tile mappings in +# sync with the tilebank PNG (extracted from $2800). + +@name TITLE +@tilebank 0 +@music 0 +@bgColor 0 +@borderColor 0 +@taxiSpawn 18 2 + +# Pads auto-detected from landable surfaces (>= 3 wide, +# approachable from above). Verify and adjust as needed. +@pad A 6 3 3 +@pad B 17 3 5 +@pad C 5 5 4 +@pad D 24 5 4 +@pad E 29 5 5 +@pad F 10 7 5 +@pad G 27 7 3 +@pad H 15 9 5 + +@fare A B +@fare B C +@fare C D +@fare D E +@fare E F +@fare F G +@fare G H +@fare H A + +# Screen-code -> alphabet-char mapping (one per unique code). +# @tile '.' = $C64_screencode_20 +# @tile '1' = $C64_screencode_84 +# @tile '2' = $C64_screencode_66 +# @tile '3' = $C64_screencode_62 +# @tile '4' = $C64_screencode_5D +# @tile '5' = $C64_screencode_40 +# @tile '6' = $C64_screencode_5E +# @tile '7' = $C64_screencode_09 +# @tile '8' = $C64_screencode_0F +# @tile '9' = $C64_screencode_13 +# @tile 'A' = $C64_screencode_05 +# @tile 'B' = $C64_screencode_08 +# @tile 'C' = $C64_screencode_12 +# @tile 'D' = $C64_screencode_14 +# @tile 'E' = $C64_screencode_02 +# @tile 'F' = $C64_screencode_03 +# @tile 'G' = $C64_screencode_07 +# @tile 'H' = $C64_screencode_0E +# @tile 'I' = $C64_screencode_19 +# @tile 'J' = $C64_screencode_52 +# @tile 'K' = $C64_screencode_0C +# @tile 'L' = $C64_screencode_15 +# @tile 'M' = $C64_screencode_46 +# @tile 'N' = $C64_screencode_4F +# @tile 'O' = $C64_screencode_54 +# @tile 'P' = $C64_screencode_04 +# @tile 'Q' = $C64_screencode_06 +# @tile 'R' = $C64_screencode_2C +# @tile 'S' = $C64_screencode_2E +# @tile 'T' = $C64_screencode_41 +# @tile 'U' = $C64_screencode_45 +# @tile 'V' = $C64_screencode_4A +# @tile 'W' = $C64_screencode_4E +# @tile 'X' = $C64_screencode_50 +# @tile 'Y' = $C64_screencode_55 +# @tile 'Z' = $C64_screencode_57 +# @tile 'a' = $C64_screencode_0B +# @tile 'b' = $C64_screencode_10 +# @tile 'c' = $C64_screencode_16 +# @tile 'd' = $C64_screencode_26 +# @tile 'e' = $C64_screencode_31 +# @tile 'f' = $C64_screencode_34 +# @tile 'g' = $C64_screencode_38 +# @tile 'h' = $C64_screencode_39 +# @tile 'i' = $C64_screencode_3A +# @tile 'j' = $C64_screencode_42 +# @tile 'k' = $C64_screencode_43 +# @tile 'l' = $C64_screencode_44 +# @tile 'm' = $C64_screencode_49 +# @tile 'n' = $C64_screencode_4B +# @tile 'o' = $C64_screencode_53 +# @tile 'p' = $C64_screencode_5C +# @tile 'q' = $C64_screencode_5F +# @tile 'r' = $C64_screencode_60 +# @tile 's' = $C64_screencode_61 +# @tile 't' = $C64_screencode_BC + +@tilemap +r33333333333333333333333333333333333333s +4.....1111.1111....1....1111.11111.....6 +4....1.....1...1..1.1..1.....1.........6 +4.....111..1111..11111.1.....1111......6 +4........1.1.....1...1.1.....1.........6 +4....1111..1.....1...1..1111.11111.....6 +4......................................6 +4.........11111..1...1...1.111.........6 +4...........1...1.1...1.1...1..........6 +4...........1..11111...1....1..........6 +4...........1..1...1..1.1...1..........6 +4...........1..1...1.1...1.111.........6 +2222222222222222222222222222222222222222 +4...........EI.V8BH.MS.nLDFBAC.........6 +4......................................6 +4............d.k8bIC7GBD.ehgf..........6 +4..........TKK.J7GBD9.JA9ACcAP.........6 +4......................................6 +4..V8I9D7Fai.YX.Q8C.B7GB.9F8CA9R.......6 +4............lNZW.Q8C.7H9DCLFD78H9R....6 +4............MmJU.jYOONW.D8.EAG7HS.....6 +4.XLEK79BAP...................555555555q +4.......EI..................t........... +4....................................... +p555555555555.................oNMOZTJU.. + +@colormap +1111111111111111111111111111111111111111 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +1666666666666666666666666666666666666691 +CCCCCCCBBBBBBBBBBBBBBBBBBBBBBBBBBCCCCCCC +199999999999CCCCCCCCCCCCCCCCCC0199999991 +1999999999999900000000000000000999999991 +1999999999999C1CCCCCCCCCCCCCC00099999991 +19999999991CCCCCCCCCCCCCCCCCCC0999999991 +1999990000000000000000000000000099999991 +1CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC00999991 +1000CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC09991 +109CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC009991 +10CCCCCCCCCCC009000000000000001111111111 +10000000CCC00099999999999990109999999990 +1999999999999999999999999990000000000000 +1111111111111999999999999999301111111100 diff --git a/assets/sprites/sprites.png b/assets/sprites/sprites.png new file mode 100644 index 0000000..9411d66 Binary files /dev/null and b/assets/sprites/sprites.png differ diff --git a/assets/tiles/tbank0.png b/assets/tiles/tbank0.png new file mode 100644 index 0000000..d073394 Binary files /dev/null and b/assets/tiles/tbank0.png differ diff --git a/assets/tiles/tbank1.png b/assets/tiles/tbank1.png new file mode 100644 index 0000000..58bed90 Binary files /dev/null and b/assets/tiles/tbank1.png differ diff --git a/assets/tiles/tbank2.png b/assets/tiles/tbank2.png new file mode 100644 index 0000000..77de8aa Binary files /dev/null and b/assets/tiles/tbank2.png differ diff --git a/extractFromDump.py b/extractFromDump.py new file mode 100644 index 0000000..e7b792d --- /dev/null +++ b/extractFromDump.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +# extractFromDump.py -- convert a VICE C64 memory dump into JoeyLib +# Space Taxi assets. +# +# Reads: /tmp/spacetaxi/mem0000-.bin (VICE `save` output; +# 2-byte LE start-addr header + 64 KB RAM). +# Writes: examples/spacetaxi/assets/tiles/tilebank.png +# examples/spacetaxi/assets/levels/level.txt +# examples/spacetaxi/assets/sprites/sprites.png (level1 only) +# examples/spacetaxi/assets/font.png (level1 only) +# +# Per-level: +# - Custom charset at $2800-$2FFF (2 KB, 256 chars * 8 bytes mono) +# becomes a tile bank PNG: 40 cols x 25 rows of 8x8 cells, with +# tile index N at cell (N%40, N/40). The PNG uses the EGA-ish +# 16-color palette we already install on stage. +# - Screen RAM at $0400-$07E7 (1000 bytes = 40x25 char codes) and +# color RAM at $D800-$DBE7 (1000 bytes = 40x25 palette nybbles) +# become the tilemap+colormap in the level .txt source. +# +# Sprite data at $3000-$37FF + sprite pointer table at $07F8-$07FF +# is decoded once (it's the same across levels for the active set): +# - sprite 0 / 7: the taxi cab body & shadow +# - sprite 1..6: passenger frames / animation cels +# 64 bytes per sprite, 24x21 1-bit, MSB-leftmost. Color comes from +# $D027-$D02E (one color per sprite). +# +# Usage: +# python3 extractFromDump.py stuff/spacetaxi/mem0000-level1.bin level01 1 +# python3 extractFromDump.py stuff/spacetaxi/mem0000-level2.bin level02 2 +# args: +# +# Dumps live in stuff/spacetaxi/ (committed-ish, repo-local) rather +# than /tmp so they survive reboots and stay with the project. + +import os +import struct +import sys +from collections import Counter + +from PIL import Image + + +# C64 standard color palette (EGA-ish 16-color JoeyLib mapping). +# Indices 0..15 match the C64 VIC-II color register order so a value +# read from $D8xx or $D02x maps directly to a JoeyLib palette slot. +# Colors below approximate Pepto's standard PAL palette in $RGB form. +C64_PALETTE_RGB = [ + (0x00, 0x00, 0x00), # 0 black + (0xFF, 0xFF, 0xFF), # 1 white + (0x88, 0x39, 0x32), # 2 red + (0x67, 0xB6, 0xBD), # 3 cyan + (0x8B, 0x3F, 0x96), # 4 purple + (0x55, 0xA0, 0x49), # 5 green + (0x40, 0x31, 0x8D), # 6 blue + (0xBF, 0xCE, 0x72), # 7 yellow + (0x8B, 0x54, 0x29), # 8 orange + (0x57, 0x42, 0x00), # 9 brown + (0xB8, 0x69, 0x62), # 10 light red + (0x50, 0x50, 0x50), # 11 dark gray + (0x78, 0x78, 0x78), # 12 mid gray + (0x94, 0xE0, 0x89), # 13 light green + (0x78, 0x69, 0xC4), # 14 light blue + (0x9F, 0x9F, 0x9F), # 15 light gray +] + + +ROOT = os.path.dirname(os.path.abspath(__file__)) + + +def load_dump(path): + with open(path, "rb") as f: + raw = f.read() + start = raw[0] | (raw[1] << 8) + if start != 0: + raise SystemExit(f"{path}: unexpected start ${start:04X}, want $0000") + if len(raw) - 2 != 0x10000: + raise SystemExit(f"{path}: not a 64K dump (got {len(raw) - 2} bytes)") + return raw[2:] + + +def new_canvas(w, h): + img = Image.new("P", (w, h), 0) + flat = bytearray() + for r, g, b in C64_PALETTE_RGB: + flat.extend((r, g, b)) + flat.extend(b"\x00" * (768 - len(flat))) + img.putpalette(bytes(flat)) + return img + + +def paint_charset_glyph(px, mem, src_code, bx, by): + """Plot the 8x8 glyph for C64 screen code `src_code` at pixel + (bx, by) of the indexed-palette PIL image. Foreground = palette + index 1, background untouched (already index 0 from new_canvas). + """ + src = 0x2800 + src_code * 8 + for r in range(8): + byte = mem[src + r] + for c in range(8): + if byte & (0x80 >> c): + px[bx + c, by + r] = 1 + + +def extract_tilebank(mem, out_path, code_to_slot=None): + """Render the custom charset at $2800-$2FFF as a 320x200 PNG. + When `code_to_slot` is None, lays out all 256 chars at their raw + C64 positions (40 cols x 25 rows). When `code_to_slot` is given, + writes each charset glyph at the slot the level loader expects + -- i.e. the bank tile at position N is the glyph for screen-code + `code_for_slot[N]`. That keeps level data and tilebank aligned + after the level extractor's frequency-sorted code remapping. + """ + img = new_canvas(320, 200) + px = img.load() + if code_to_slot is None: + for ch in range(256): + paint_charset_glyph(px, mem, ch, (ch % 40) * 8, (ch // 40) * 8) + else: + # Invert: slot -> code. Multiple codes can collide into slot 0 + # (background); only the first one we see "owns" that slot for + # painting purposes -- the rest just fall through to the same + # blank tile. Highest priority is the explicit space mapping. + slot_to_code = {} + for code, slot in code_to_slot.items(): + slot_to_code.setdefault(slot, code) + for slot, code in slot_to_code.items(): + if slot >= 256: + continue + paint_charset_glyph(px, mem, code, (slot % 40) * 8, (slot // 40) * 8) + img.save(out_path, "PNG", optimize=True) + + +def build_code_to_slot(mem): + """Build the screen-code -> alphabet-slot map shared between the + tilebank emission and the level emission. The slot index is what + the runtime sees in level.tilemap[]; the tilebank must mirror this + ordering so a level-data slot-N cell renders the charset glyph + that was actually used in the original screen RAM at that code. + """ + screen = mem[0x0400:0x0400 + 1000] + codes = sorted(Counter(screen).items(), key=lambda kv: (-kv[1], kv[0])) + code_to_slot = {} + next_slot = 0 + for code, _count in codes: + if code == 0x20: # space -> always index 0 + code_to_slot[code] = 0 + continue + if code in code_to_slot: + continue + if next_slot >= len(ALPHABET): + code_to_slot[code] = 0 + continue + # Reserve slot 0 for background (space). The very first + # non-space code claims slot 1, etc. + if next_slot == 0: + next_slot = 1 + code_to_slot[code] = next_slot + next_slot += 1 + return code_to_slot + + +def screencode_to_char(code): + """Convert a C64 screen code into a printable tilemap character + using the encoding the .txt format already understands: + 0 -> '.' (treated as 0) + 1..9 -> '1'..'9' (digits encode the lower 10 indices) + but we shift to use up most of the index space below. + For arbitrary 0..255 we need a 256-symbol alphabet -- not feasible + in a single character per cell. So we encode as TWO chars per cell + using base-16 hex would still only cover 0..255. Decision: keep + .txt cells one-char and map screen codes via a custom 64-char + alphabet plus an @char directive table emitted into the file so + the loader knows exactly which screen code each char represents. + """ + # This function is unused as a one-char encoder; we encode via the + # generic alphabet below instead. + return code + + +# Generic 1-char alphabet (62 codes); the level loader's index map +# was: '.' or ' '=0, '0'..'9'=0..9, 'A'..'Z'=10..35, 'a'..'z'=36..61. +# That's only 62 codes, not 256. The original C64 levels use ~30 +# distinct screen codes per level so most levels fit. We map the N +# most-frequent screen codes to those alphabet slots. +ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + +def extract_level(mem, level_name, tilebank_id, out_path, code_to_alpha): + """Render screen RAM + color RAM as an STL1-format .txt source + using the prebuilt screen-code -> alphabet-slot mapping. Caller + must pass the same mapping that was used to reorder the tilebank + PNG; otherwise the level tilemap and tilebank diverge. + """ + screen = mem[0x0400:0x0400 + 1000] # 40x25 + color = mem[0xD800:0xD800 + 1000] # 40x25, low nybble = palette slot + codes = sorted(Counter(screen).items(), key=lambda kv: (-kv[1], kv[0])) + + # Full 25-row C64 screen. The title screen uses all 25 rows + # (PUBLISHED ... SOFTWARE credit wraps across rows 21, 22, 24 + # with frame chars interleaved). Gameplay HUD draws over the + # bottom 3 rows at runtime. + playfield_rows = 25 + + lines = [] + lines.append(f"# Extracted from VICE memory dump. Edit at your own risk;") + lines.append(f"# regenerating will overwrite. Keep the @tile mappings in") + lines.append(f"# sync with the tilebank PNG (extracted from $2800).") + lines.append(f"") + lines.append(f"@name {level_name.upper()}") + lines.append(f"@tilebank {tilebank_id}") + lines.append(f"@music 0") + bg = mem[0xD021] & 0x0F + border = mem[0xD020] & 0x0F + lines.append(f"@bgColor {bg}") + lines.append(f"@borderColor {border}") + + # Best-effort taxi spawn: middle-top of playfield. The original + # script-driven spawn position would need to be lifted out of + # game state at dump time; for now hand-edit if you want it + # to match the cracker's preferred spawn. + lines.append(f"@taxiSpawn 18 2") + lines.append(f"") + + # Pads: scan the playfield for "landable surfaces" -- runs of + # >= 3 identical non-background tile cells with two or more + # empty rows directly above (so the taxi can approach from the + # top). Per-row scanning runs top-to-bottom; for the same + # surface tile across rows we only take the topmost contiguous + # platform edge. + bg_code = codes[0][0] if codes else 0x20 + def is_bg(code): + return code == bg_code or code == 0x20 + + pads = [] + seen_used = [False] * (playfield_rows * 40) + for row in range(2, playfield_rows): + line = screen[row * 40:(row + 1) * 40] + prev = screen[(row - 1) * 40:row * 40] + prev2 = screen[(row - 2) * 40:(row - 1) * 40] + col = 0 + while col < 40 and len(pads) < 8: + if is_bg(line[col]): + col += 1 + continue + j = col + while j < 40 and line[j] == line[col] and not seen_used[row * 40 + j]: + j += 1 + width = j - col + # Approachable from above? At least 2/3 of cells in the + # 2 rows above this stretch must be background. + above_clear = 0 + for k in range(col, j): + if is_bg(prev[k]): + above_clear += 1 + if is_bg(prev2[k]): + above_clear += 1 + if width >= 3 and above_clear >= width: + pads.append((row, col, width)) + for k in range(col, j): + seen_used[row * 40 + k] = True + col = j + + lines.append("# Pads auto-detected from landable surfaces (>= 3 wide,") + lines.append("# approachable from above). Verify and adjust as needed.") + pad_letter = ord("A") + for (row, col, width) in pads: + if pad_letter > ord("H"): + break + lines.append(f"@pad {chr(pad_letter)} {col} {row} {width}") + pad_letter += 1 + if pad_letter == ord("A"): + lines.append("# (No landable surfaces auto-detected; emitting placeholders.)") + lines.append("@pad A 4 10 4") + lines.append("@pad B 32 10 4") + pad_letter = ord("C") + + lines.append("") + # Fare list: simple round-robin between detected pads. + pad_count = pad_letter - ord("A") + if pad_count >= 2: + for k in range(pad_count): + src = chr(ord("A") + k) + dst = chr(ord("A") + ((k + 1) % pad_count)) + lines.append(f"@fare {src} {dst} 45") + else: + lines.append("@fare A B 45") + lines.append("") + + # Emit the @tile mapping for round-trip clarity (and so the + # author can read a level .txt and tell which screen code maps + # to which char). Format: @tile -- the + # mkstlevel parser ignores these today but they're useful + # documentation, and a future loader could honor them. + lines.append("# Screen-code -> alphabet-char mapping (one per unique code).") + inv = {v: k for k, v in code_to_alpha.items()} + for idx in sorted(inv): + ch = '.' if idx == 0 else ALPHABET[idx] + code = inv[idx] + lines.append(f"# @tile '{ch}' = $C64_screencode_{code:02X}") + lines.append("") + + # Tilemap (22 rows of 40 chars each). + lines.append("@tilemap") + for r in range(playfield_rows): + row = screen[r * 40:(r + 1) * 40] + s = "" + for code in row: + idx = code_to_alpha.get(code, 0) + s += '.' if idx == 0 else ALPHABET[idx] + lines.append(s) + + lines.append("") + lines.append("@colormap") + for r in range(playfield_rows): + row = color[r * 40:(r + 1) * 40] + s = "".join( + ('0' if (c & 0x0F) == 0 + else ALPHABET[(c & 0x0F)] + if (c & 0x0F) < len(ALPHABET) else '0') + for c in row + ) + lines.append(s) + + with open(out_path, "w") as fp: + fp.write("\n".join(lines) + "\n") + + +def extract_sprites(mem, out_path): + """Render C64 hardware sprites into the JoeyLib-expected sprite + sheet layout (320x200, 16-color indexed PNG): + y= 0..23 : 1 taxi cel (24x24, x = 0) + sprite 0 ptr (in level1 dump: $DC -> $3700) + The adjacent slot $DD is the landing-gear-down + pose, not a thrust frame -- ignored for now. + y= 24..47 : 4 passenger cels (16x16 each, x = 0, 16, 32, 48) + Sprite 3 ptr ($07FB) is the passenger data; sprites 3-6 share it. + """ + img = new_canvas(320, 200) + px = img.load() + + # Shared multicolor sprite colors from VIC registers. + mcm_color0 = mem[0xD025] & 0x0F # 2-bit value 01 + mcm_color1 = mem[0xD026] & 0x0F # 2-bit value 11 + mcm_mask = mem[0xD01C] # bit per sprite: 1 = multicolor + + def paint_sprite_at(data_addr, sprite_color, dst_x, dst_y, dst_w, dst_h, multicolor=False): + # Mono C64 sprite is 24x21, 1 bit per pixel, 3 bytes per row. + # Multicolor C64 sprite is 12 "double-wide pixels" x 21 rows, + # 2 bits per pixel, still 3 bytes per row. Color mapping: + # 00 -> transparent + # 01 -> mcm_color0 ($D025) + # 10 -> sprite_color (sprite's own $D027+sp) + # 11 -> mcm_color1 ($D026) + if sprite_color == 0: + sprite_color = 1 + sx_max = min(24, dst_w) + sy_max = min(21, dst_h) + for r in range(sy_max): + if multicolor: + for byte_idx in range(3): + byte = mem[data_addr + r * 3 + byte_idx] + # 4 multicolor pixels per byte; each spans 2 + # hardware pixels wide. + for pp in range(4): + bits = (byte >> ((3 - pp) * 2)) & 0x03 + if bits == 0: + continue + if bits == 1: + col = mcm_color0 + elif bits == 2: + col = sprite_color + else: + col = mcm_color1 + sx0 = byte_idx * 8 + pp * 2 + if sx0 + 1 >= sx_max: + break + px[dst_x + sx0, dst_y + r] = col + px[dst_x + sx0 + 1, dst_y + r] = col + else: + for byte_idx in range(3): + byte = mem[data_addr + r * 3 + byte_idx] + for bit in range(8): + sx = byte_idx * 8 + bit + if sx >= sx_max: + break + if byte & (0x80 >> bit): + px[dst_x + sx, dst_y + r] = sprite_color + + def paint_c64_sprite(sp_index, dst_x, dst_y, dst_w, dst_h): + ptr = mem[0x07F8 + sp_index] + if ptr == 0: + return False + sprite_color = mem[0xD027 + sp_index] & 0x0F + is_multicolor = bool(mcm_mask & (1 << sp_index)) + paint_sprite_at(ptr * 64, sprite_color, dst_x, dst_y, dst_w, dst_h, + multicolor=is_multicolor) + return True + + # Single taxi cel from sprite 0's actual pointer. + paint_c64_sprite(0, 0, 0, 24, 24) + + # 4 passenger cels = the shared C64 sprite 3 (sprites 3..6 all + # point to the same data in the level-1 dump). + for cel in range(4): + paint_c64_sprite(3, cel * 16, 24, 16, 16) + + img.save(out_path, "PNG", optimize=True) + + +def extract_font(mem, out_path): + """Render the custom charset (which IS the in-game font in C64 + text-mode games) as the 320x200 font.png. Same layout as + tilebank, but here we want each glyph at cell (ascii%40, + ascii/40) for jlDrawText's ASCII map. + + For Space Taxi specifically, the charset doubles as the level + tile bank AND any in-screen text Sierra-style. We map the C64 + screen codes used for ASCII display (codes 1..26 = A..Z, + 32..63 = punctuation/digits) directly to their ASCII positions + in the JoeyLib font sheet. + """ + img = new_canvas(320, 200) + px = img.load() + + def render_at(ascii_code, charset_idx): + if ascii_code < 32 or ascii_code >= 128: + return + src = 0x2800 + (charset_idx & 0xFF) * 8 + col = ascii_code % 40 + row = ascii_code // 40 + bx = col * 8 + by = row * 8 + for r in range(8): + byte = mem[src + r] + for c in range(8): + if byte & (0x80 >> c): + px[bx + c, by + r] = 1 + + # C64 upper-only screen codes: 1..26 = A..Z, 27=[ 28=£ 29=] 30=↑ 31=← + # 32 = space, 33..63 = !"# ... ?, etc. We map ASCII directly. + for c in range(32, 64): + render_at(c, c) # symbols + digits 1:1 + for c, code in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ"): + render_at(ord(code), 1 + c) # screen code 1..26 -> A..Z + + img.save(out_path, "PNG", optimize=True) + + +def main(): + if len(sys.argv) != 4: + print("usage: extractFromDump.py ", file=sys.stderr) + sys.exit(2) + dump_path = sys.argv[1] + level_name = sys.argv[2] + tilebank_id = int(sys.argv[3]) + + mem = load_dump(dump_path) + print(f"loaded {dump_path}: 64 KB OK") + + tiles_dir = os.path.join(ROOT, "assets", "tiles") + sprites_dir = os.path.join(ROOT, "assets", "sprites") + levels_dir = os.path.join(ROOT, "assets", "levels") + os.makedirs(tiles_dir, exist_ok=True) + os.makedirs(sprites_dir, exist_ok=True) + os.makedirs(levels_dir, exist_ok=True) + + # Build the screen-code -> bank-slot mapping once and feed it to + # both extractors so the tilemap and tilebank stay aligned. + code_to_slot = build_code_to_slot(mem) + + # 8.3 DOS naming -- "tilebank0" is 9 chars and fopen fails under + # DOSBox strict 8.3 mode. Keep the short form across the pipeline. + tilebank_path = os.path.join(tiles_dir, f"tbank{tilebank_id}.png") + extract_tilebank(mem, tilebank_path, code_to_slot) + print(f" wrote {tilebank_path}") + + level_path = os.path.join(levels_dir, f"{level_name}.txt") + extract_level(mem, level_name, tilebank_id, level_path, code_to_slot) + print(f" wrote {level_path}") + + # Sprites + font come from level 1 only (sprite pointer table is + # script-driven so it differs by scene; we use the first dump's + # set as the canonical sheet, plus its charset as the font). + if tilebank_id == 1: + sprite_path = os.path.join(sprites_dir, "sprites.png") + extract_sprites(mem, sprite_path) + print(f" wrote {sprite_path}") + + font_path = os.path.join(ROOT, "assets", "font.png") + extract_font(mem, font_path) + print(f" wrote {font_path}") + + +if __name__ == "__main__": + main() diff --git a/generated/amiga/font.tbk b/generated/amiga/font.tbk new file mode 100644 index 0000000..61466ad Binary files /dev/null and b/generated/amiga/font.tbk differ diff --git a/generated/amiga/levels/level01.dat b/generated/amiga/levels/level01.dat new file mode 100644 index 0000000..18889fc Binary files /dev/null and b/generated/amiga/levels/level01.dat differ diff --git a/generated/amiga/levels/level02.dat b/generated/amiga/levels/level02.dat new file mode 100644 index 0000000..dd590ea Binary files /dev/null and b/generated/amiga/levels/level02.dat differ diff --git a/generated/amiga/levels/level03.dat b/generated/amiga/levels/level03.dat new file mode 100644 index 0000000..40c531a Binary files /dev/null and b/generated/amiga/levels/level03.dat differ diff --git a/generated/amiga/levels/level04.dat b/generated/amiga/levels/level04.dat new file mode 100644 index 0000000..0115781 Binary files /dev/null and b/generated/amiga/levels/level04.dat differ diff --git a/generated/amiga/levels/level05.dat b/generated/amiga/levels/level05.dat new file mode 100644 index 0000000..0b5268d Binary files /dev/null and b/generated/amiga/levels/level05.dat differ diff --git a/generated/amiga/levels/level06.dat b/generated/amiga/levels/level06.dat new file mode 100644 index 0000000..8cac764 Binary files /dev/null and b/generated/amiga/levels/level06.dat differ diff --git a/generated/amiga/levels/level07.dat b/generated/amiga/levels/level07.dat new file mode 100644 index 0000000..b0114fa Binary files /dev/null and b/generated/amiga/levels/level07.dat differ diff --git a/generated/amiga/levels/level08.dat b/generated/amiga/levels/level08.dat new file mode 100644 index 0000000..18842ba Binary files /dev/null and b/generated/amiga/levels/level08.dat differ diff --git a/generated/amiga/levels/level09.dat b/generated/amiga/levels/level09.dat new file mode 100644 index 0000000..28f3d58 Binary files /dev/null and b/generated/amiga/levels/level09.dat differ diff --git a/generated/amiga/levels/level10.dat b/generated/amiga/levels/level10.dat new file mode 100644 index 0000000..c67d49c Binary files /dev/null and b/generated/amiga/levels/level10.dat differ diff --git a/generated/amiga/levels/level11.dat b/generated/amiga/levels/level11.dat new file mode 100644 index 0000000..635f902 Binary files /dev/null and b/generated/amiga/levels/level11.dat differ diff --git a/generated/amiga/levels/level12.dat b/generated/amiga/levels/level12.dat new file mode 100644 index 0000000..13417e5 Binary files /dev/null and b/generated/amiga/levels/level12.dat differ diff --git a/generated/amiga/levels/level13.dat b/generated/amiga/levels/level13.dat new file mode 100644 index 0000000..1b8046a Binary files /dev/null and b/generated/amiga/levels/level13.dat differ diff --git a/generated/amiga/levels/level14.dat b/generated/amiga/levels/level14.dat new file mode 100644 index 0000000..651e35e Binary files /dev/null and b/generated/amiga/levels/level14.dat differ diff --git a/generated/amiga/levels/level15.dat b/generated/amiga/levels/level15.dat new file mode 100644 index 0000000..957a058 Binary files /dev/null and b/generated/amiga/levels/level15.dat differ diff --git a/generated/amiga/levels/level16.dat b/generated/amiga/levels/level16.dat new file mode 100644 index 0000000..d7057b9 Binary files /dev/null and b/generated/amiga/levels/level16.dat differ diff --git a/generated/amiga/levels/level17.dat b/generated/amiga/levels/level17.dat new file mode 100644 index 0000000..194b264 Binary files /dev/null and b/generated/amiga/levels/level17.dat differ diff --git a/generated/amiga/levels/level18.dat b/generated/amiga/levels/level18.dat new file mode 100644 index 0000000..a283dc5 Binary files /dev/null and b/generated/amiga/levels/level18.dat differ diff --git a/generated/amiga/levels/level19.dat b/generated/amiga/levels/level19.dat new file mode 100644 index 0000000..c7dfc6e Binary files /dev/null and b/generated/amiga/levels/level19.dat differ diff --git a/generated/amiga/levels/level20.dat b/generated/amiga/levels/level20.dat new file mode 100644 index 0000000..ac96165 Binary files /dev/null and b/generated/amiga/levels/level20.dat differ diff --git a/generated/amiga/levels/level21.dat b/generated/amiga/levels/level21.dat new file mode 100644 index 0000000..a342bd3 Binary files /dev/null and b/generated/amiga/levels/level21.dat differ diff --git a/generated/amiga/levels/level22.dat b/generated/amiga/levels/level22.dat new file mode 100644 index 0000000..1ce7d95 Binary files /dev/null and b/generated/amiga/levels/level22.dat differ diff --git a/generated/amiga/levels/level23.dat b/generated/amiga/levels/level23.dat new file mode 100644 index 0000000..e83b4c4 Binary files /dev/null and b/generated/amiga/levels/level23.dat differ diff --git a/generated/amiga/levels/level24.dat b/generated/amiga/levels/level24.dat new file mode 100644 index 0000000..f623c48 Binary files /dev/null and b/generated/amiga/levels/level24.dat differ diff --git a/generated/amiga/levels/title.dat b/generated/amiga/levels/title.dat new file mode 100644 index 0000000..0773fae Binary files /dev/null and b/generated/amiga/levels/title.dat differ diff --git a/generated/amiga/sprites/sprites.spr b/generated/amiga/sprites/sprites.spr new file mode 100644 index 0000000..8dd31fe Binary files /dev/null and b/generated/amiga/sprites/sprites.spr differ diff --git a/generated/amiga/tiles/tbank0.tbk b/generated/amiga/tiles/tbank0.tbk new file mode 100644 index 0000000..a7c5a92 Binary files /dev/null and b/generated/amiga/tiles/tbank0.tbk differ diff --git a/generated/amiga/tiles/tbank1.tbk b/generated/amiga/tiles/tbank1.tbk new file mode 100644 index 0000000..1b1b788 Binary files /dev/null and b/generated/amiga/tiles/tbank1.tbk differ diff --git a/generated/amiga/tiles/tbank2.tbk b/generated/amiga/tiles/tbank2.tbk new file mode 100644 index 0000000..58c3ff2 Binary files /dev/null and b/generated/amiga/tiles/tbank2.tbk differ diff --git a/generated/atarist/font.tbk b/generated/atarist/font.tbk new file mode 100644 index 0000000..d77d9d1 Binary files /dev/null and b/generated/atarist/font.tbk differ diff --git a/generated/atarist/levels/level01.dat b/generated/atarist/levels/level01.dat new file mode 100644 index 0000000..18889fc Binary files /dev/null and b/generated/atarist/levels/level01.dat differ diff --git a/generated/atarist/levels/level02.dat b/generated/atarist/levels/level02.dat new file mode 100644 index 0000000..dd590ea Binary files /dev/null and b/generated/atarist/levels/level02.dat differ diff --git a/generated/atarist/levels/level03.dat b/generated/atarist/levels/level03.dat new file mode 100644 index 0000000..40c531a Binary files /dev/null and b/generated/atarist/levels/level03.dat differ diff --git a/generated/atarist/levels/level04.dat b/generated/atarist/levels/level04.dat new file mode 100644 index 0000000..0115781 Binary files /dev/null and b/generated/atarist/levels/level04.dat differ diff --git a/generated/atarist/levels/level05.dat b/generated/atarist/levels/level05.dat new file mode 100644 index 0000000..0b5268d Binary files /dev/null and b/generated/atarist/levels/level05.dat differ diff --git a/generated/atarist/levels/level06.dat b/generated/atarist/levels/level06.dat new file mode 100644 index 0000000..8cac764 Binary files /dev/null and b/generated/atarist/levels/level06.dat differ diff --git a/generated/atarist/levels/level07.dat b/generated/atarist/levels/level07.dat new file mode 100644 index 0000000..b0114fa Binary files /dev/null and b/generated/atarist/levels/level07.dat differ diff --git a/generated/atarist/levels/level08.dat b/generated/atarist/levels/level08.dat new file mode 100644 index 0000000..18842ba Binary files /dev/null and b/generated/atarist/levels/level08.dat differ diff --git a/generated/atarist/levels/level09.dat b/generated/atarist/levels/level09.dat new file mode 100644 index 0000000..28f3d58 Binary files /dev/null and b/generated/atarist/levels/level09.dat differ diff --git a/generated/atarist/levels/level10.dat b/generated/atarist/levels/level10.dat new file mode 100644 index 0000000..c67d49c Binary files /dev/null and b/generated/atarist/levels/level10.dat differ diff --git a/generated/atarist/levels/level11.dat b/generated/atarist/levels/level11.dat new file mode 100644 index 0000000..635f902 Binary files /dev/null and b/generated/atarist/levels/level11.dat differ diff --git a/generated/atarist/levels/level12.dat b/generated/atarist/levels/level12.dat new file mode 100644 index 0000000..13417e5 Binary files /dev/null and b/generated/atarist/levels/level12.dat differ diff --git a/generated/atarist/levels/level13.dat b/generated/atarist/levels/level13.dat new file mode 100644 index 0000000..1b8046a Binary files /dev/null and b/generated/atarist/levels/level13.dat differ diff --git a/generated/atarist/levels/level14.dat b/generated/atarist/levels/level14.dat new file mode 100644 index 0000000..651e35e Binary files /dev/null and b/generated/atarist/levels/level14.dat differ diff --git a/generated/atarist/levels/level15.dat b/generated/atarist/levels/level15.dat new file mode 100644 index 0000000..957a058 Binary files /dev/null and b/generated/atarist/levels/level15.dat differ diff --git a/generated/atarist/levels/level16.dat b/generated/atarist/levels/level16.dat new file mode 100644 index 0000000..d7057b9 Binary files /dev/null and b/generated/atarist/levels/level16.dat differ diff --git a/generated/atarist/levels/level17.dat b/generated/atarist/levels/level17.dat new file mode 100644 index 0000000..194b264 Binary files /dev/null and b/generated/atarist/levels/level17.dat differ diff --git a/generated/atarist/levels/level18.dat b/generated/atarist/levels/level18.dat new file mode 100644 index 0000000..a283dc5 Binary files /dev/null and b/generated/atarist/levels/level18.dat differ diff --git a/generated/atarist/levels/level19.dat b/generated/atarist/levels/level19.dat new file mode 100644 index 0000000..c7dfc6e Binary files /dev/null and b/generated/atarist/levels/level19.dat differ diff --git a/generated/atarist/levels/level20.dat b/generated/atarist/levels/level20.dat new file mode 100644 index 0000000..ac96165 Binary files /dev/null and b/generated/atarist/levels/level20.dat differ diff --git a/generated/atarist/levels/level21.dat b/generated/atarist/levels/level21.dat new file mode 100644 index 0000000..a342bd3 Binary files /dev/null and b/generated/atarist/levels/level21.dat differ diff --git a/generated/atarist/levels/level22.dat b/generated/atarist/levels/level22.dat new file mode 100644 index 0000000..1ce7d95 Binary files /dev/null and b/generated/atarist/levels/level22.dat differ diff --git a/generated/atarist/levels/level23.dat b/generated/atarist/levels/level23.dat new file mode 100644 index 0000000..e83b4c4 Binary files /dev/null and b/generated/atarist/levels/level23.dat differ diff --git a/generated/atarist/levels/level24.dat b/generated/atarist/levels/level24.dat new file mode 100644 index 0000000..f623c48 Binary files /dev/null and b/generated/atarist/levels/level24.dat differ diff --git a/generated/atarist/levels/title.dat b/generated/atarist/levels/title.dat new file mode 100644 index 0000000..0773fae Binary files /dev/null and b/generated/atarist/levels/title.dat differ diff --git a/generated/atarist/sprites/sprites.spr b/generated/atarist/sprites/sprites.spr new file mode 100644 index 0000000..8dd31fe Binary files /dev/null and b/generated/atarist/sprites/sprites.spr differ diff --git a/generated/atarist/tiles/tbank0.tbk b/generated/atarist/tiles/tbank0.tbk new file mode 100644 index 0000000..d49247a Binary files /dev/null and b/generated/atarist/tiles/tbank0.tbk differ diff --git a/generated/atarist/tiles/tbank1.tbk b/generated/atarist/tiles/tbank1.tbk new file mode 100644 index 0000000..c4a9cb1 Binary files /dev/null and b/generated/atarist/tiles/tbank1.tbk differ diff --git a/generated/atarist/tiles/tbank2.tbk b/generated/atarist/tiles/tbank2.tbk new file mode 100644 index 0000000..26619da Binary files /dev/null and b/generated/atarist/tiles/tbank2.tbk differ diff --git a/generated/dos/font.tbk b/generated/dos/font.tbk new file mode 100644 index 0000000..01ce59e Binary files /dev/null and b/generated/dos/font.tbk differ diff --git a/generated/dos/levels/level01.dat b/generated/dos/levels/level01.dat new file mode 100644 index 0000000..18889fc Binary files /dev/null and b/generated/dos/levels/level01.dat differ diff --git a/generated/dos/levels/level02.dat b/generated/dos/levels/level02.dat new file mode 100644 index 0000000..dd590ea Binary files /dev/null and b/generated/dos/levels/level02.dat differ diff --git a/generated/dos/levels/level03.dat b/generated/dos/levels/level03.dat new file mode 100644 index 0000000..40c531a Binary files /dev/null and b/generated/dos/levels/level03.dat differ diff --git a/generated/dos/levels/level04.dat b/generated/dos/levels/level04.dat new file mode 100644 index 0000000..0115781 Binary files /dev/null and b/generated/dos/levels/level04.dat differ diff --git a/generated/dos/levels/level05.dat b/generated/dos/levels/level05.dat new file mode 100644 index 0000000..0b5268d Binary files /dev/null and b/generated/dos/levels/level05.dat differ diff --git a/generated/dos/levels/level06.dat b/generated/dos/levels/level06.dat new file mode 100644 index 0000000..8cac764 Binary files /dev/null and b/generated/dos/levels/level06.dat differ diff --git a/generated/dos/levels/level07.dat b/generated/dos/levels/level07.dat new file mode 100644 index 0000000..b0114fa Binary files /dev/null and b/generated/dos/levels/level07.dat differ diff --git a/generated/dos/levels/level08.dat b/generated/dos/levels/level08.dat new file mode 100644 index 0000000..18842ba Binary files /dev/null and b/generated/dos/levels/level08.dat differ diff --git a/generated/dos/levels/level09.dat b/generated/dos/levels/level09.dat new file mode 100644 index 0000000..28f3d58 Binary files /dev/null and b/generated/dos/levels/level09.dat differ diff --git a/generated/dos/levels/level10.dat b/generated/dos/levels/level10.dat new file mode 100644 index 0000000..c67d49c Binary files /dev/null and b/generated/dos/levels/level10.dat differ diff --git a/generated/dos/levels/level11.dat b/generated/dos/levels/level11.dat new file mode 100644 index 0000000..635f902 Binary files /dev/null and b/generated/dos/levels/level11.dat differ diff --git a/generated/dos/levels/level12.dat b/generated/dos/levels/level12.dat new file mode 100644 index 0000000..13417e5 Binary files /dev/null and b/generated/dos/levels/level12.dat differ diff --git a/generated/dos/levels/level13.dat b/generated/dos/levels/level13.dat new file mode 100644 index 0000000..1b8046a Binary files /dev/null and b/generated/dos/levels/level13.dat differ diff --git a/generated/dos/levels/level14.dat b/generated/dos/levels/level14.dat new file mode 100644 index 0000000..651e35e Binary files /dev/null and b/generated/dos/levels/level14.dat differ diff --git a/generated/dos/levels/level15.dat b/generated/dos/levels/level15.dat new file mode 100644 index 0000000..957a058 Binary files /dev/null and b/generated/dos/levels/level15.dat differ diff --git a/generated/dos/levels/level16.dat b/generated/dos/levels/level16.dat new file mode 100644 index 0000000..d7057b9 Binary files /dev/null and b/generated/dos/levels/level16.dat differ diff --git a/generated/dos/levels/level17.dat b/generated/dos/levels/level17.dat new file mode 100644 index 0000000..194b264 Binary files /dev/null and b/generated/dos/levels/level17.dat differ diff --git a/generated/dos/levels/level18.dat b/generated/dos/levels/level18.dat new file mode 100644 index 0000000..a283dc5 Binary files /dev/null and b/generated/dos/levels/level18.dat differ diff --git a/generated/dos/levels/level19.dat b/generated/dos/levels/level19.dat new file mode 100644 index 0000000..c7dfc6e Binary files /dev/null and b/generated/dos/levels/level19.dat differ diff --git a/generated/dos/levels/level20.dat b/generated/dos/levels/level20.dat new file mode 100644 index 0000000..ac96165 Binary files /dev/null and b/generated/dos/levels/level20.dat differ diff --git a/generated/dos/levels/level21.dat b/generated/dos/levels/level21.dat new file mode 100644 index 0000000..a342bd3 Binary files /dev/null and b/generated/dos/levels/level21.dat differ diff --git a/generated/dos/levels/level22.dat b/generated/dos/levels/level22.dat new file mode 100644 index 0000000..1ce7d95 Binary files /dev/null and b/generated/dos/levels/level22.dat differ diff --git a/generated/dos/levels/level23.dat b/generated/dos/levels/level23.dat new file mode 100644 index 0000000..e83b4c4 Binary files /dev/null and b/generated/dos/levels/level23.dat differ diff --git a/generated/dos/levels/level24.dat b/generated/dos/levels/level24.dat new file mode 100644 index 0000000..f623c48 Binary files /dev/null and b/generated/dos/levels/level24.dat differ diff --git a/generated/dos/levels/title.dat b/generated/dos/levels/title.dat new file mode 100644 index 0000000..0773fae Binary files /dev/null and b/generated/dos/levels/title.dat differ diff --git a/generated/dos/sprites/sprites.spr b/generated/dos/sprites/sprites.spr new file mode 100644 index 0000000..8dd31fe Binary files /dev/null and b/generated/dos/sprites/sprites.spr differ diff --git a/generated/dos/tiles/tbank0.tbk b/generated/dos/tiles/tbank0.tbk new file mode 100644 index 0000000..63caf7c Binary files /dev/null and b/generated/dos/tiles/tbank0.tbk differ diff --git a/generated/dos/tiles/tbank1.tbk b/generated/dos/tiles/tbank1.tbk new file mode 100644 index 0000000..a05ddbb Binary files /dev/null and b/generated/dos/tiles/tbank1.tbk differ diff --git a/generated/dos/tiles/tbank2.tbk b/generated/dos/tiles/tbank2.tbk new file mode 100644 index 0000000..89dcab9 Binary files /dev/null and b/generated/dos/tiles/tbank2.tbk differ diff --git a/mkstlevel/mkstlevel.c b/mkstlevel/mkstlevel.c new file mode 100644 index 0000000..ba1afa4 --- /dev/null +++ b/mkstlevel/mkstlevel.c @@ -0,0 +1,499 @@ +// mkstlevel: convert a human-readable text level definition into the +// STL2 binary format read by examples/spacetaxi/stLevel.c. +// +// Usage: +// mkstlevel input.txt output.dat +// +// Input format (.txt, line-oriented, comments start with '#'): +// +// @name +// @tilebank <0..255> +// @music <0..255> +// @bgColor +// @borderColor +// @taxiSpawn +// @accel [optional, default 14 14] +// @gravity [optional, default 0 1; yGrav signed] +// @vicColor [optional, default all 0] +// @pad [up to 10 of these] +// @fare [up to 16 of these] +// @tilemap -- followed by exactly 25 lines of 40 chars each +// @colormap -- followed by exactly 25 lines of 40 chars each +// +// Tilemap / colormap character encoding (chars are tile/palette indices): +// '.' or ' ' -> 0 (background) +// '0'..'9' -> 0..9 +// 'A'..'Z' -> 10..35 +// 'a'..'z' -> 36..61 +// +// Per the spacetaxi engine convention: +// tile index 0 = empty (non-solid) +// tile index 1..63 = solid (walls) +// tile index 64..127 = landing-pad surface +// tile index 128..255 = decorative non-solid +// +// Color cells use the same character->index decoding; cell values are +// palette slots 0..15. Use '0'..'9','a'..'f' for the standard 16 +// surface palettes. + +#include +#include +#include +#include +#include + +#define ST_TILEMAP_W 40 +#define ST_PLAYFIELD_ROWS 25 +#define ST_MAX_PADS 10 +#define ST_MAX_FARES 16 +#define ST_NAME_MAX 23 +#define LINE_MAX 256 + + +typedef struct { + uint8_t letter; + uint8_t tileX; + uint8_t tileY; + uint8_t tileW; +} PadT; + + +typedef struct { + uint8_t spawnPad; + uint8_t destPad; +} FareT; + + +typedef struct { + char name[ST_NAME_MAX + 1]; + uint8_t tileBank; + uint8_t music; + uint8_t bgColor; + uint8_t borderColor; + uint8_t taxiSpawnX; + uint8_t taxiSpawnY; + uint8_t xAccel; + uint8_t yAccel; + int8_t xGrav; + int8_t yGrav; + uint8_t bgColor1; + uint8_t bgColor2; + uint8_t bgColor3; + uint8_t spriteMc0; + uint8_t spriteMc1; + uint8_t sprite0Color; + uint8_t sprite1Color; + uint8_t padCount; + PadT pads[ST_MAX_PADS]; + uint8_t fareCount; + FareT fares[ST_MAX_FARES]; + uint8_t tilemap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS]; + uint8_t colormap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS]; +} LevelT; + + +static int charToIndex(int c, int *outIdx); +static int findPadByLetter(const LevelT *L, uint8_t letter); +static int parseGrid(FILE *fp, const char *gridName, uint8_t *outCells, int *outLine); +static int parseLevel(FILE *fp, LevelT *out); +static int readLine(FILE *fp, char *buf, int n); +static int stripLine(char *s); +static int writeOut(const LevelT *L, const char *path); + + +static int charToIndex(int c, int *outIdx) { + if (c == '.' || c == ' ') { + *outIdx = 0; + return 1; + } + if (c >= '0' && c <= '9') { *outIdx = c - '0'; return 1; } + if (c >= 'A' && c <= 'Z') { *outIdx = 10 + (c - 'A'); return 1; } + if (c >= 'a' && c <= 'z') { *outIdx = 36 + (c - 'a'); return 1; } + return 0; +} + + +static int findPadByLetter(const LevelT *L, uint8_t letter) { + uint8_t i; + for (i = 0; i < L->padCount; i++) { + if (L->pads[i].letter == letter) { + return (int)i; + } + } + return -1; +} + + +// Reads one line; returns >0 on success, 0 on EOF, -1 on error. +// Strips trailing newline. Lines starting with '#' are returned with +// the leading '#' so the caller can decide whether to skip them. +static int readLine(FILE *fp, char *buf, int n) { + int c; + int k = 0; + + while (k < n - 1) { + c = fgetc(fp); + if (c == EOF) { + buf[k] = '\0'; + return (k == 0) ? 0 : 1; + } + if (c == '\n') { + break; + } + if (c == '\r') { + continue; // tolerate CRLF + } + buf[k++] = (char)c; + } + buf[k] = '\0'; + return 1; +} + + +// Strip leading whitespace; return 1 if blank-or-comment, 0 if data. +static int stripLine(char *s) { + int i = 0; + int j = 0; + while (s[i] == ' ' || s[i] == '\t') { + i++; + } + if (s[i] == '\0' || s[i] == '#') { + return 1; + } + while (s[i] != '\0') { + s[j++] = s[i++]; + } + s[j] = '\0'; + return 0; +} + + +static int parseGrid(FILE *fp, const char *gridName, uint8_t *outCells, int *outLine) { + char line[LINE_MAX]; + int row; + int col; + int idx; + + for (row = 0; row < ST_PLAYFIELD_ROWS; row++) { + if (readLine(fp, line, sizeof(line)) <= 0) { + fprintf(stderr, "mkstlevel: %s: unexpected EOF on row %d\n", + gridName, row); + return 0; + } + (*outLine)++; + // Strip CRLF/trailing space but DON'T treat indent as significant; + // do NOT strip leading whitespace within the grid (chars are literal). + { + int len = (int)strlen(line); + while (len > 0 && (line[len-1] == ' ' || line[len-1] == '\t')) { + line[--len] = '\0'; + } + // Allow a leading '#' comment line inside the grid only if it + // appears in the first column; otherwise treat as data error. + if (line[0] == '#') { + row--; // re-run this slot + continue; + } + if (len == 0) { + row--; // skip blank lines silently + continue; + } + if (len != ST_TILEMAP_W) { + fprintf(stderr, + "mkstlevel: %s line %d: expected %d chars, got %d (\"%s\")\n", + gridName, *outLine, ST_TILEMAP_W, len, line); + return 0; + } + } + for (col = 0; col < ST_TILEMAP_W; col++) { + if (!charToIndex((unsigned char)line[col], &idx)) { + fprintf(stderr, + "mkstlevel: %s line %d col %d: bad char '%c' (0x%02X)\n", + gridName, *outLine, col + 1, line[col], + (unsigned)(unsigned char)line[col]); + return 0; + } + outCells[row * ST_TILEMAP_W + col] = (uint8_t)idx; + } + } + return 1; +} + + +static int parseLevel(FILE *fp, LevelT *out) { + char line[LINE_MAX]; + int lineNo = 0; + int gotTilemap = 0; + int gotColormap = 0; + + memset(out, 0, sizeof(*out)); + out->bgColor = 0; + out->borderColor = 0; + // Physics defaults match level 1's templates so hand-authored + // levels without an @accel/@gravity directive still play sanely. + out->xAccel = 14; + out->yAccel = 14; + out->xGrav = 0; + out->yGrav = 1; + + while (readLine(fp, line, sizeof(line)) > 0) { + lineNo++; + if (stripLine(line)) { + continue; + } + if (line[0] != '@') { + fprintf(stderr, + "mkstlevel: line %d: expected @directive or comment, got \"%s\"\n", + lineNo, line); + return 0; + } + + if (strncmp(line, "@name ", 6) == 0) { + int len = (int)strlen(line + 6); + if (len > ST_NAME_MAX) { + len = ST_NAME_MAX; + } + memcpy(out->name, line + 6, (size_t)len); + out->name[len] = '\0'; + } else if (strncmp(line, "@tilebank ", 10) == 0) { + out->tileBank = (uint8_t)atoi(line + 10); + } else if (strncmp(line, "@music ", 7) == 0) { + out->music = (uint8_t)atoi(line + 7); + } else if (strncmp(line, "@bgColor ", 9) == 0) { + out->bgColor = (uint8_t)atoi(line + 9); + } else if (strncmp(line, "@borderColor ", 13) == 0) { + out->borderColor = (uint8_t)atoi(line + 13); + } else if (strncmp(line, "@taxiSpawn ", 11) == 0) { + int x = 0; + int y = 0; + if (sscanf(line + 11, "%d %d", &x, &y) != 2) { + fprintf(stderr, "mkstlevel: line %d: bad taxiSpawn\n", lineNo); + return 0; + } + out->taxiSpawnX = (uint8_t)x; + out->taxiSpawnY = (uint8_t)y; + } else if (strncmp(line, "@accel ", 7) == 0) { + int ax = 0; + int ay = 0; + if (sscanf(line + 7, "%d %d", &ax, &ay) != 2) { + fprintf(stderr, "mkstlevel: line %d: bad accel\n", lineNo); + return 0; + } + out->xAccel = (uint8_t)ax; + out->yAccel = (uint8_t)ay; + } else if (strncmp(line, "@gravity ", 9) == 0) { + int gx = 0; + int gy = 0; + if (sscanf(line + 9, "%d %d", &gx, &gy) != 2) { + fprintf(stderr, "mkstlevel: line %d: bad gravity\n", lineNo); + return 0; + } + out->xGrav = (int8_t)gx; + out->yGrav = (int8_t)gy; + } else if (strncmp(line, "@vicColor ", 10) == 0) { + int b1 = 0; + int b2 = 0; + int b3 = 0; + int mc0 = 0; + int mc1 = 0; + int sc0 = 0; + int sc1 = 0; + if (sscanf(line + 10, "%d %d %d %d %d %d %d", + &b1, &b2, &b3, &mc0, &mc1, &sc0, &sc1) != 7) { + fprintf(stderr, "mkstlevel: line %d: bad vicColor (need 7 values)\n", lineNo); + return 0; + } + out->bgColor1 = (uint8_t)b1; + out->bgColor2 = (uint8_t)b2; + out->bgColor3 = (uint8_t)b3; + out->spriteMc0 = (uint8_t)mc0; + out->spriteMc1 = (uint8_t)mc1; + out->sprite0Color = (uint8_t)sc0; + out->sprite1Color = (uint8_t)sc1; + } else if (strncmp(line, "@pad ", 5) == 0) { + char letter = '\0'; + int px = 0; + int py = 0; + int pw = 0; + if (sscanf(line + 5, " %c %d %d %d", &letter, &px, &py, &pw) != 4) { + fprintf(stderr, "mkstlevel: line %d: bad pad\n", lineNo); + return 0; + } + if (out->padCount >= ST_MAX_PADS) { + fprintf(stderr, "mkstlevel: line %d: too many pads (max %d)\n", + lineNo, ST_MAX_PADS); + return 0; + } + out->pads[out->padCount].letter = (uint8_t)letter; + out->pads[out->padCount].tileX = (uint8_t)px; + out->pads[out->padCount].tileY = (uint8_t)py; + out->pads[out->padCount].tileW = (uint8_t)pw; + out->padCount++; + } else if (strncmp(line, "@fare ", 6) == 0) { + char src = '\0'; + char dst = '\0'; + int srcIdx; + int dstIdx; + if (sscanf(line + 6, " %c %c", &src, &dst) != 2) { + fprintf(stderr, "mkstlevel: line %d: bad fare\n", lineNo); + return 0; + } + srcIdx = findPadByLetter(out, (uint8_t)src); + dstIdx = findPadByLetter(out, (uint8_t)dst); + if (srcIdx < 0 || dstIdx < 0) { + fprintf(stderr, + "mkstlevel: line %d: fare references unknown pad (src '%c' dst '%c')\n", + lineNo, src, dst); + return 0; + } + if (out->fareCount >= ST_MAX_FARES) { + fprintf(stderr, "mkstlevel: line %d: too many fares (max %d)\n", + lineNo, ST_MAX_FARES); + return 0; + } + out->fares[out->fareCount].spawnPad = (uint8_t)srcIdx; + out->fares[out->fareCount].destPad = (uint8_t)dstIdx; + out->fareCount++; + } else if (strcmp(line, "@tilemap") == 0) { + if (!parseGrid(fp, "tilemap", out->tilemap, &lineNo)) { + return 0; + } + gotTilemap = 1; + } else if (strcmp(line, "@colormap") == 0) { + if (!parseGrid(fp, "colormap", out->colormap, &lineNo)) { + return 0; + } + gotColormap = 1; + } else { + fprintf(stderr, "mkstlevel: line %d: unknown directive \"%s\"\n", + lineNo, line); + return 0; + } + } + + if (out->name[0] == '\0') { + fprintf(stderr, "mkstlevel: missing @name\n"); + return 0; + } + if (!gotTilemap) { + fprintf(stderr, "mkstlevel: missing @tilemap\n"); + return 0; + } + if (!gotColormap) { + fprintf(stderr, "mkstlevel: missing @colormap\n"); + return 0; + } + if (out->padCount == 0) { + fprintf(stderr, "mkstlevel: at least one @pad required\n"); + return 0; + } + return 1; +} + + +static int writeOut(const LevelT *L, const char *path) { + FILE *fp; + uint8_t nameLen; + uint8_t i; + size_t cells; + + fp = fopen(path, "wb"); + if (fp == NULL) { + fprintf(stderr, "mkstlevel: cannot open %s for write\n", path); + return 0; + } + if (fputc('S', fp) == EOF || fputc('T', fp) == EOF || + fputc('L', fp) == EOF || fputc('2', fp) == EOF) { + goto fail; + } + nameLen = (uint8_t)strlen(L->name); + if (fputc(nameLen, fp) == EOF) { + goto fail; + } + if (fwrite(L->name, 1, nameLen, fp) != nameLen) { + goto fail; + } + if (fputc(L->tileBank, fp) == EOF || + fputc(L->music, fp) == EOF || + fputc(L->bgColor, fp) == EOF || + fputc(L->borderColor, fp) == EOF || + fputc(L->taxiSpawnX, fp) == EOF || + fputc(L->taxiSpawnY, fp) == EOF) { + goto fail; + } + if (fputc(L->xAccel, fp) == EOF || + fputc(L->yAccel, fp) == EOF || + fputc((uint8_t)L->xGrav, fp) == EOF || + fputc((uint8_t)L->yGrav, fp) == EOF) { + goto fail; + } + if (fputc(L->bgColor1, fp) == EOF || + fputc(L->bgColor2, fp) == EOF || + fputc(L->bgColor3, fp) == EOF || + fputc(L->spriteMc0, fp) == EOF || + fputc(L->spriteMc1, fp) == EOF || + fputc(L->sprite0Color, fp) == EOF || + fputc(L->sprite1Color, fp) == EOF) { + goto fail; + } + if (fputc(L->padCount, fp) == EOF) { + goto fail; + } + for (i = 0; i < L->padCount; i++) { + if (fputc(L->pads[i].letter, fp) == EOF || + fputc(L->pads[i].tileX, fp) == EOF || + fputc(L->pads[i].tileY, fp) == EOF || + fputc(L->pads[i].tileW, fp) == EOF) { + goto fail; + } + } + if (fputc(L->fareCount, fp) == EOF) { + goto fail; + } + for (i = 0; i < L->fareCount; i++) { + if (fputc(L->fares[i].spawnPad, fp) == EOF || + fputc(L->fares[i].destPad, fp) == EOF) { + goto fail; + } + } + cells = (size_t)ST_TILEMAP_W * ST_PLAYFIELD_ROWS; + if (fwrite(L->tilemap, 1, cells, fp) != cells) { + goto fail; + } + if (fwrite(L->colormap, 1, cells, fp) != cells) { + goto fail; + } + fclose(fp); + return 1; +fail: + fclose(fp); + fprintf(stderr, "mkstlevel: write error\n"); + return 0; +} + + +int main(int argc, char **argv) { + FILE *fp; + LevelT L; + + if (argc != 3) { + fprintf(stderr, "usage: %s input.txt output.dat\n", argv[0]); + return 2; + } + fp = fopen(argv[1], "r"); + if (fp == NULL) { + fprintf(stderr, "mkstlevel: cannot open %s\n", argv[1]); + return 1; + } + if (!parseLevel(fp, &L)) { + fclose(fp); + return 1; + } + fclose(fp); + if (!writeOut(&L, argv[2])) { + return 1; + } + printf("mkstlevel: wrote %s (name=\"%s\", pads=%u, fares=%u)\n", + argv[2], L.name, (unsigned)L.padCount, (unsigned)L.fareCount); + return 0; +} diff --git a/per-level-data.txt b/per-level-data.txt new file mode 100644 index 0000000..f04d86f --- /dev/null +++ b/per-level-data.txt @@ -0,0 +1,26 @@ +Lvl Pads SpawnY SpawnX X-acc Y-grav Border/BG Pad-style-bytes (byte 7 of each 8-byte slot) +-------------------------------------------------------------------------------------------------- +A 1 $4A00 $0A00 $0E $01 $00/$00 $C2 +B 3 $8800 $8800 $11 $01 $00/$00 $C2 $C2 $C4 +C 5 $4400 $AA00 $12 $02 $00/$00 $C2 $C2 $C4 $C2 $C4 +D 10 $8800 $8800 $11 $06 $00/$00 $FF $FF $FF $FF $FF $FF $FF $FF $FF $FF +E 9 $C800 $FA00 $11 $06 $09/$00 $C2 $C2 $C4 $C2 $C4 $C2 $C4 $C2 $C4 +F 3 $6400 $AA00 $15 $03 $00/$00 $C2 $C4 $C4 +G 5 $5600 $AF00 $14 $06 $0C/$00 $C2 $C2 $C2 $C2 $C4 +H 5 $3E00 $9600 $19 $06 $00/$00 $C4 $C4 $C2 $C2 $C2 +I 7 $6E00 $AA00 $15 $03 $00/$00 $C2 $C2 $C2 $C2 $C4 $C4 $C4 +J 6 $3E00 $1E00 $19 $05 $09/$00 $C2 $C2 $C4 $C2 $C4 $C4 +K 5 $5F00 $B400 $17 $F9 $00/$00 $C2 $C2 $C2 $C4 $C4 +L 6 $4600 $6E00 $1A $03 $0B/$0B $C2 $C4 $C2 $C2 $C4 $C4 +M 10 $3E00 $AF00 $40 $06 $06/$00 $C2 $C2 $C4 $C4 $C4 $C4 $C4 $C2 $C2 $C2 +N 8 $3E00 $AF00 $12 $06 $00/$00 $C4 $C2 $C2 $C4 $C4 $C4 $C2 $C2 +O 1 $3400 $2E00 $15 $03 $00/$00 $C2 +P 3 $4A00 $2400 $15 $06 $0B/$00 $C2 $C4 $C2 +Q 8 $4600 $B400 $19 $05 $0B/$00 $C4 $C2 $C2 $C4 $C2 $C4 $C2 $C4 +R 1 $3800 $E600 $10 $01 $00/$00 $C4 +S 5 $4400 $AA00 $12 $03 $07/$08 $C2 $C2 $C4 $C2 $C4 +T 8 $C800 $AF00 $1B $06 $06/$00 $C2 $C4 $C2 $C2 $C4 $C4 $C2 $C4 +U 5 $4A00 $AA00 $18 $02 $00/$00 $C4 $C2 $C2 $C4 $C2 +V 7 $3E00 $AF00 $1E $06 $06/$00 $C2 $C2 $C2 $C2 $C2 $C4 $C4 +W 3 $4600 $3200 $1E $03 $00/$00 $C4 $C2 $C2 +X 8 $4600 $AF00 $17 $06 $0B/$00 $C4 $C4 $C4 $C4 $C2 $C2 $C2 $C2 diff --git a/spacetaxi.c b/spacetaxi.c new file mode 100644 index 0000000..732b534 --- /dev/null +++ b/spacetaxi.c @@ -0,0 +1,256 @@ +// Space Taxi (JoeyLib port) -- main loop and game state machine. +// +// Build: see make/{dos,amiga,atarist,iigs}.mk -- target is +// `EXAMPLE=spacetaxi`. +// +// Runtime layout: +// 1. jlInit + jlStageGet +// 2. stRenderInit loads tile and sprite asset banks from disk +// 3. State machine: title -> level-intro -> playing -> done +// 4. Each frame: +// - jlInputPoll (read joystick + keyboard) +// - stEngineTick (taxi physics) +// - stPassengerTick (passenger AI) +// - stRenderFrame (sprite save/restore + draw + HUD) +// - jlAudioFrameTick +// - jlWaitVBL + +#include +#include + +#include "spacetaxi.h" + + +static void applyInput(StGameT *game); +static bool loadLevelByIndex(StGameT *game, uint8_t idx); + + +// All 24 canonical Space Taxi levels (A..X), extracted from the C64 +// ROM via stuff/spacetaxi/romToLevel.py. level01 = scene 0 = "A", +// level24 = scene 23 = "X". +static const char *gLevelPaths[] = { + "DATA/levels/level01.dat", "DATA/levels/level02.dat", + "DATA/levels/level03.dat", "DATA/levels/level04.dat", + "DATA/levels/level05.dat", "DATA/levels/level06.dat", + "DATA/levels/level07.dat", "DATA/levels/level08.dat", + "DATA/levels/level09.dat", "DATA/levels/level10.dat", + "DATA/levels/level11.dat", "DATA/levels/level12.dat", + "DATA/levels/level13.dat", "DATA/levels/level14.dat", + "DATA/levels/level15.dat", "DATA/levels/level16.dat", + "DATA/levels/level17.dat", "DATA/levels/level18.dat", + "DATA/levels/level19.dat", "DATA/levels/level20.dat", + "DATA/levels/level21.dat", "DATA/levels/level22.dat", + "DATA/levels/level23.dat", "DATA/levels/level24.dat", +}; + +#define ST_LEVEL_COUNT (sizeof(gLevelPaths) / sizeof(gLevelPaths[0])) + + +static void applyInput(StGameT *game) { + StTaxiT *t = &game->taxi; + int8_t jx; + int8_t jy; + bool up; + bool down; + bool left; + bool right; + + // Joystick port 2 is the canonical Space Taxi control. Keyboard + // is the fallback so the same binary is testable on hosts without + // a stick. jlJoystickX/Y return -127..127; treat anything past + // 1/4 deflection as "held in that direction". + jx = jlJoystickX(JOYSTICK_0); + jy = jlJoystickY(JOYSTICK_0); + up = jlKeyDown(KEY_UP) || jy < -32; + down = jlKeyDown(KEY_DOWN) || jy > 32; + left = jlKeyDown(KEY_LEFT) || jx < -32; + right = jlKeyDown(KEY_RIGHT) || jx > 32; + + // C64 Space Taxi gameplay only acts on the 4 directional bits of + // $DC00; the fire button is masked out except on menu/title + // screens. (Confirmed via disassembly at $6040-$60D6 in mem0801: + // the main input handler only loads X/Y velocity templates when + // L/R or U/D are held, fire is never tested in this path.) + // Menu fire-to-advance is handled by main()'s state switch. + t->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0)); + t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0)); + t->thrusting = (up || down || left || right); + + // Facing is purely cosmetic (sprite cel selection). Update when + // horizontal thrust is commanded; keep last facing otherwise so + // a stopped cab stays facing where it was. + if (left) { + t->facing = ST_DIR_LEFT; + } else if (right) { + t->facing = ST_DIR_RIGHT; + } +} + + +static bool loadLevelByIndex(StGameT *game, uint8_t idx) { + if (idx >= ST_LEVEL_COUNT) { + return false; + } + if (!stLevelLoad(&game->level, gLevelPaths[idx])) { + return false; + } + game->levelIndex = idx; + stRenderLevelChanged(); + return true; +} + + +int main(void) { + jlConfigT config; + jlSurfaceT *stage; + StGameT game; + + // Sprite codegen arena: after Phase 11 (shared-walker rewrite of + // the planar sprite path), sprites no longer consume arena bytes + // -- the per-cel work happens inside halSpriteDrawPlanes / + // halSpriteSavePlanes / halSpriteRestorePlanes as static lib code. + // 32 KB is plenty for whatever other codegen needs are around. + config.codegenBytes = 32UL * 1024; + config.maxSurfaces = 4; // stage + work + config.audioBytes = 32UL * 1024; // music + SFX + + if (!jlInit(&config)) { + fprintf(stderr, "jlInit: %s\n", jlLastError()); + return 1; + } + jlLogReset(); + jlLogF("spacetaxi: build=%s %s", __DATE__, __TIME__); + + stage = jlStageGet(); + if (stage == NULL) { + jlLogF("spacetaxi: ! jlStageGet returned NULL"); + jlShutdown(); + return 1; + } + + memset(&game, 0, sizeof(game)); + game.state = ST_STATE_TITLE; + game.lives = 5; + game.levelIndex = 0; + game.fareTarget = 1; // C64 default: 1 fare per game (see $48AF). + + jlLogF("spacetaxi: stRenderInit ..."); + stRenderInit(stage); + jlLogF("spacetaxi: stAudioInit ..."); + stAudioInit(); + + // Load the title-screen tilemap. raw.bin captured the C64 game + // at the title screen, so its screen RAM at $0400 IS the title + // (the big SPACE TAXI letters, JOHN F. BUTCHER credit, joystick + // instructions, etc.). romToLevel.py emits title.dat from that + // capture. Falls through to a black field if the asset is + // missing. + jlLogF("spacetaxi: stLevelLoad title.dat ..."); + if (stLevelLoad(&game.level, "DATA/levels/title.dat")) { + jlLogF("spacetaxi: title loaded, tilebank=%u", + (unsigned)game.level.tileBankId); + stRenderLevelChanged(); + } else { + jlLogF("spacetaxi: title load FAILED"); + } + jlLogFlush(); + + for (;;) { + jlInputPoll(); + if (jlKeyPressed(KEY_ESCAPE)) { + break; + } + + switch (game.state) { + case ST_STATE_TITLE: + { + // C64 title only listens for UP / DOWN / FIRE per the + // baked joystick instructions ("UP FOR HIGH SCORES, + // DOWN FOR INSTRUCTIONS, FIRE BUTTON TO BEGIN"). The + // fare-count selector ($5310-$5328) lives on a separate + // options/menu scene at $5295 (header strings) and + // $52E1/$533C (the "1 2 3 4" digit row), not the main + // title. UP / DOWN / options screen are TODO; for now + // the title only honors FIRE -> begin. + if (jlKeyPressed(KEY_SPACE) || + jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) { + game.score = 0; + game.lives = 5; + game.levelIndex = 0; + if (!loadLevelByIndex(&game, 0)) { + jlLogF("spacetaxi: ! cannot load level 0 (DATA/levels/level01.dat)"); + jlLogFlush(); + goto shutdown; + } + stEngineReset(&game); + // C64 goes straight from title to gameplay; no + // intermediate "PRESS FIRE TO START" screen. + game.state = ST_STATE_PLAYING; + } + break; + } + case ST_STATE_PLAYING: + { + bool wasLanded = game.taxi.landed; + applyInput(&game); + stEngineTick(&game); + stPassengerTick(&game); + // Edge-trigger SFX from state deltas. + stAudioSfxThrust(game.taxi.thrusting); + if (!wasLanded && game.taxi.landed) { + stAudioSfxLand(); + } + break; + } + case ST_STATE_LEVEL_DONE: + // No music to stop -- gameplay is silent. The C64 plays + // a score-screen jingle (song 7 / song 6) between levels; + // not yet wired up here. + if (loadLevelByIndex(&game, (uint8_t)(game.levelIndex + 1))) { + stEngineReset(&game); + game.state = ST_STATE_PLAYING; + } else { + // ran out of levels -- back to title (player wins) + if (stLevelLoad(&game.level, "DATA/levels/title.dat")) { + stRenderLevelChanged(); + } + game.state = ST_STATE_TITLE; + } + break; + case ST_STATE_GAME_OVER: + if (jlKeyPressed(KEY_SPACE)) { + // Restore the title tilemap so the press-start + // overlay isn't sitting on top of the last-played + // level's art. + if (stLevelLoad(&game.level, "DATA/levels/title.dat")) { + stRenderLevelChanged(); + } + game.state = ST_STATE_TITLE; + } + break; + default: + game.state = ST_STATE_TITLE; + break; + } + + // Silence continuous thrust SFX whenever we're not actively + // playing (title, level-intro, game-over, level-done). + if (game.state != ST_STATE_PLAYING) { + stAudioSfxThrust(false); + } + + stRenderFrame(stage, &game); + stAudioFrameTick(); + jlAudioFrameTick(); + // stRenderFrame already does jlWaitVBL right before its + // jlStagePresent (sync-on-present), so an extra wait here + // would just slow the loop to half framerate. + } + +shutdown: + + stAudioShutdown(); + stRenderShutdown(); + jlShutdown(); + return 0; +} diff --git a/spacetaxi.h b/spacetaxi.h new file mode 100644 index 0000000..5ae4f1b --- /dev/null +++ b/spacetaxi.h @@ -0,0 +1,252 @@ +// Space Taxi (JoeyLib port) -- shared types and decls. +// +// Architecture (all four JoeyLib targets): +// +// tilemap 40x25 cells, each cell = (tileIndex, paletteSlot). +// jlTilePaste blits the tile bank into the stage one cell +// at a time at level boot, then it's static until the +// scene changes. No per-frame redraw of the tilemap. +// +// taxi Single jlSpriteT with multiple cels (thrust frames, +// facing variants). Save-under + restore-under each frame +// so we don't repaint the tilemap behind it. +// +// passenger Up to 2 simultaneous sprites: one waiting on a pad, +// one already in the cab (or none). Same save/restore +// discipline as the taxi. +// +// audio One ~3-voice event stream rendered per platform: +// SB+PSG-synth (DOS), PT 4-voice (Amiga), YM2149 (ST), +// Ensoniq DOC channels (IIgs). The dispatch lives in +// stAudio.c with one entry point per voice command. +// +// input Joystick port 2 conventionally on the C64; we map +// thrust to fire and direction to all four cardinals. +// Keyboard fallback: arrows + space for hosts without +// joysticks. +// +// Level data lives in a small custom .dat per level (see +// assets/levels/format.md). Tile bitmaps and sprite cels are PNG +// authored externally and baked to native .tbk / .spr blobs at +// build time via tools/assetbake/assetbake.py. + +#ifndef SPACETAXI_H +#define SPACETAXI_H + +#include + +#define ST_TILEMAP_W 40u +#define ST_TILEMAP_H 25u +#define ST_TILE_PIXELS 8u + +// Display field is 320x200 (JoeyLib's SURFACE_WIDTH x SURFACE_HEIGHT). +// 40 tiles x 8 px = 320, 25 tiles x 8 px = 200. The bottom 3 rows are +// the HUD band (score / lives / level / current-fare strip). The top +// 22 rows are the playfield where the taxi moves. +// Full C64 screen height. The original title uses all 25 rows +// (frame at 0, 12, 24 + content). For gameplay the bottom 3 rows +// are HUD territory -- HUD draws over the tilemap there. +#define ST_PLAYFIELD_ROWS 25u +#define ST_HUD_ROW 22u +#define ST_HUD_ROW_COUNT 3u + +// Maximum stuff. Tuned for fitting the smallest target (IIgs): +// 10 pads is the highest count in any canonical Space Taxi level +// (D and M each have 10 pads; everything else <= 9) +// 2 active passenger sprites covers waiting + carrying +#define ST_MAX_PADS 10u +#define ST_MAX_PASSENGERS 2u +#define ST_MAX_FARES 16u + +// Fixed-point taxi physics: position and velocity are int16_t in +// units of 1/16 px, so the taxi can drift fractionally and the +// thrust/gravity terms are integers without losing precision over +// the whole field. (22 rows x 8 px x 16 = 2816 < 32767 so int16 +// is fine.) +// Match the C64 fixed-point scale: 8-bit sub-pixel (256 sub-units per +// pixel) so the C64's accel=14 and gravity=1 are usable directly +// (14/256 px/frame initial accel; constant 1/256 px/frame gravity). +// Position needs int32_t since a 320-wide playfield * 256 sub-units +// overflows int16_t. +#define ST_SUBPIXEL_SHIFT 8 +#define ST_SUBPIXEL (1 << ST_SUBPIXEL_SHIFT) + +// Taxi thrust cel cycling. The sprite asset +// (assets/genPlaceholderArt.py) lays out 4 taxi cels: +// cel 0 = idle (no thrust) +// cel 1..3 = thrust-flame frames (cycled while input is held) +// thrustFrame on StTaxiT counts 0..(ST_THRUST_CEL_COUNT * +// ST_THRUST_CEL_TICKS - 1) while thrusting. Renderer divides by +// ST_THRUST_CEL_TICKS to pick cel 1, 2, or 3. +#define ST_THRUST_CEL_COUNT 3u +#define ST_THRUST_CEL_TICKS 2u + + +typedef enum { + ST_STATE_TITLE = 0, + ST_STATE_PLAYING, + ST_STATE_LEVEL_DONE, + ST_STATE_GAME_OVER +} StGameStateE; + + +typedef enum { + ST_DIR_RIGHT = 0, + ST_DIR_LEFT = 1 +} StFacingE; + + +typedef struct { + uint8_t letter; // 'A'..'H' identifier (which pad number) + uint8_t tileX; // landing surface left edge (tile coord) + uint8_t tileY; // landing surface row (tile coord) + uint8_t tileW; // pad width in tiles +} StPadT; + + +typedef struct { + uint8_t spawnPad; // pad index where they appear + uint8_t destPad; // pad index they want to go to +} StFareT; + + +typedef struct { + char name[24]; // level display name ("UP & DOWN", etc.) + uint8_t tileBankId; // which tile asset (0 = default bank) + uint8_t musicId; // UNUSED in C64 Space Taxi -- the + // game has no per-level background + // music; gameplay is silent except + // for SFX. Title plays song 8, score + // screen plays song 6 or 7 (see + // MECHANICS.md "Sound (SID)"). Kept + // here as scaffolding for a possible + // future "level-entry jingle" event. + uint8_t bgColor; // background palette slot + uint8_t borderColor; // border palette slot (for HUD if used) + uint8_t taxiSpawnTileX; + uint8_t taxiSpawnTileY; + // Per-level physics templates. Mirror the C64 templates at + // $7D8F/$7D91 (Y/X accel) and $7D93/$7D95 (Y/X gravity). Accels + // are unsigned magnitudes; gravities are int8 so a level can pull + // upward (e.g. canonical level K = -7). Hand-authored levels can + // leave them zero; loader substitutes per-level defaults below. + uint8_t xAccel; + uint8_t yAccel; + int8_t xGrav; + int8_t yGrav; + // VIC color block from C64 $7D00-$7D08, mapped to $D020-$D028 by + // $62F0 (scene-load). borderColor/bgColor above are $7D00/$7D01. + // bgColor1/2/3 and spriteMc0/1 only matter in VIC multicolor mode + // which the JoeyLib port doesn't reproduce -- they're stored so + // the .dat format stays a faithful capture of $7D00-$7D08 but the + // runtime ignores them. sprite0Color/sprite1Color drive the cab + // and flame placeholder colors when no sprite asset is authored. + uint8_t bgColor1; // $D022 (multicolor only, unused) + uint8_t bgColor2; // $D023 (multicolor only, unused) + uint8_t bgColor3; // $D024 (multicolor only, unused) + uint8_t spriteMc0; // $D025 sprite multicolor 0 (unused) + uint8_t spriteMc1; // $D026 sprite multicolor 1 (unused) + uint8_t sprite0Color; // $D027 sprite 0 (taxi) + uint8_t sprite1Color; // $D028 sprite 1 (flame) + uint8_t padCount; + StPadT pads[ST_MAX_PADS]; + uint8_t fareCount; + StFareT fares[ST_MAX_FARES]; + // Tilemap: tile-index per cell (row-major). + uint8_t tilemap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS]; + // Palette slot per cell -- which surface palette index a cell uses. + uint8_t colormap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS]; +} StLevelT; + + +typedef struct { + // 16-bit-fixed-point position (8 bits sub-pixel + 8 bits pixel), + // but stored in int32_t so a 320-wide playfield fits without + // wrap. `x >> ST_SUBPIXEL_SHIFT` is the pixel column. + int32_t x; + int32_t y; + int16_t vx; // velocity accumulator (sub-pixel/frame) + int16_t vy; + StFacingE facing; + uint8_t thrustFrame; // 0..1 parity bit driving cab-cel flicker + bool thrusting; // any directional input held this frame + int8_t thrustDx; // -1 left, 0 neutral, +1 right + int8_t thrustDy; // -1 up, 0 neutral, +1 down + bool landed; // sitting on a pad + uint8_t onPad; // pad index if landed (0xFF if none) + // Death-animation countdown. >0 means crashed -- engine keeps the + // cab integrating under gravity (no explosion visual; the C64 + // just lets the cab fall, see VERIFIED.md "Stage 1 $665F death + // branch"). Reaches 0 -> respawn (or game-over). Mirrors the + // C64's $6B24/$6B4C phase-2/3 timing. + uint8_t crashTicks; +} StTaxiT; + + +typedef struct { + bool active; + bool onboard; // in the cab (true) or waiting at pad (false) + uint8_t currentPad; // where they are if waiting + uint8_t destPad; + int16_t x; // pixel position (for waiting/walking) + int16_t y; + uint8_t walkPhase; // animation cel (0..3) + uint8_t walkDir; // 0 = walking right, 1 = walking left +} StPassengerT; + + +typedef struct { + StGameStateE state; + StLevelT level; + StTaxiT taxi; + StPassengerT passengers[ST_MAX_PASSENGERS]; + uint32_t score; + uint8_t lives; + uint8_t levelIndex; + // Player-selectable fare target (1..4). Mirrors C64 $7213, set on + // the title screen via LEFT/RIGHT joystick (see $5310-$5328 in + // MECHANICS.md). Used to cap the per-level fare count: the engine + // delivers min(level.fareCount, game.fareTarget) before advancing. + uint8_t fareTarget; +} StGameT; + + +// Public entry points (one per source file). +bool stLevelLoad(StLevelT *out, const char *path); + +void stRenderInit(jlSurfaceT *stage); +void stRenderShutdown(void); +// Blit the level's static tile art into the stage. Called once per +// scene change; falls back to colored solid tiles per index-range +// when no tile bank asset is loaded. +void stRenderLevel(jlSurfaceT *stage, const StLevelT *level); +void stRenderFrame(jlSurfaceT *stage, const StGameT *game); +// Tell the renderer the current game.level contents changed. Required +// after stLevelLoad even when the StLevelT pointer is unchanged -- +// stRenderLevel's dirty-cache compares pointers, not contents. +void stRenderLevelChanged(void); + +// Draw an ASCII string into the stage at tile coords (bx, by) using +// the loaded font asset. No-op if the font asset failed to load. +void stRenderDrawText(jlSurfaceT *stage, uint8_t bx, uint8_t by, const char *s); + +void stEngineReset(StGameT *game); +void stEngineTick(StGameT *game); + +void stPassengerReset(StGameT *game); +void stPassengerTick(StGameT *game); + +void stAudioInit(void); +void stAudioShutdown(void); +void stAudioFrameTick(void); // call once per host frame; counts down SFX +void stAudioPlayMusic(uint8_t musicId); +void stAudioStopMusic(void); +void stAudioSfxThrust(bool on); +void stAudioSfxLand(void); +void stAudioSfxPickup(void); +void stAudioSfxDropoff(void); +void stAudioSfxCrash(void); + +void stHudDraw(jlSurfaceT *stage, const StGameT *game); + +#endif diff --git a/stAudio.c b/stAudio.c new file mode 100644 index 0000000..c4e718a --- /dev/null +++ b/stAudio.c @@ -0,0 +1,235 @@ +// Space Taxi -- audio dispatch. +// +// Simple 1-voice SFX engine on top of jlAudioVoice. Each SFX call +// programs voice slot 2 (conventionally SFX) with a tone + attenuation +// and arms a frame-tick countdown. When the countdown reaches 0 the +// voice is silenced. New SFX preempt any in-progress one. +// +// Continuous thrust SFX is edge-triggered: on -> arm a looping tone, +// off -> silence. The thrust voice (slot 1) is independent of the +// transient SFX voice so a pickup chirp on top of thrust doesn't +// cut the thrust note. +// +// MUSIC MODEL (important): Space Taxi gameplay has NO background +// music. The C64 only loads songs via $CB02 at non-gameplay sites: +// - title-screen setup ($459C / $46BC) -> song 8 +// - title-init via $4741 ($477C) -> song 25 +// - score-screen draw ($4C29) -> song 7 +// - score-screen variant ($4EB6) -> song 6 +// During gameplay the IRQ music engine ticks ($CC6C) but all 3 +// voices are silent because no track pointers are loaded. +// stAudioPlayMusic / stAudioStopMusic stay as stubs for the eventual +// title-screen and score-screen jingle dispatch; do not call them +// from gameplay state transitions. + +#include "spacetaxi.h" + +JOEYLIB_SEGMENT("STAXI") + +#define ST_SFX_VOICE 2u +#define ST_THRUST_VOICE 1u + +#define ST_SFX_PICKUP_HZ 720u +#define ST_SFX_PICKUP_TICKS 20u +#define ST_SFX_DROPOFF_HZ 1100u +#define ST_SFX_DROPOFF_TICKS 25u +#define ST_SFX_LAND_HZ 90u +#define ST_SFX_LAND_TICKS 12u + +// C64-matched SFX behavior (see MECHANICS.md "Sound (SID)"): +// +// Thrust: voice 1 freq sweep. $6A63 writes $A0 = 160 to $721B at +// thrust-engage; the phase handler decrements $721B once per tick, +// LSRs it, and writes the result to both bytes of $D400/$D401 -- +// giving a SID freq word that drops over the burst. PAL C64 maps +// freq_word=$5050 -> ~1207 Hz at the start; the sweep ends near +// silence. Reproduce with per-frame freq updates on ST_THRUST_VOICE. +#define ST_THRUST_SWEEP_INIT 160u +// Atten scale on the JoeyLib audio HAL is SN76489-style: 0 = loud, +// 15 = silent. Anything >= 15 keys-off the voice (treated as silent +// by halAudioVoice on DOS). Keep all SFX in 0..14 to actually be +// audible. 6 = moderately loud; 10 = quieter. +#define ST_THRUST_ATTEN 8u +// Audible-Hz scale factor for the sweep counter -- chosen so initial +// (counter=160) lands ~1200 Hz to match the C64 PAL freq mapping. +#define ST_THRUST_HZ_PER_TICK 8u + +// Crash audio is TWO simultaneous events on the C64: +// 1. impact "bang": voice 3 noise burst ($D412 = $81 then $80 a +// short time later). Approximated here with rapid pseudo-random +// freq jumps on ST_SFX_VOICE. +// 2. descending "scream": $721B initialised to $A0 at $6A61, then +// the phase-1 handler ($6A72) LSRs it each tick into $D400/$D401 +// -- voice 1 freq drops from ~1.2 kHz toward silence over the +// death anim. Reproduced here on ST_THRUST_VOICE (same voice the +// C64 uses; the cab can't be thrusting during a crash anyway). +#define ST_SFX_CRASH_TICKS 30u +#define ST_CRASH_SCREAM_INIT 120u // matches stEngine ST_CRASH_ANIM_FRAMES + +#define ST_SFX_DEFAULT_ATTEN 6u + + +static uint16_t gSfxTicksLeft; +static bool gThrustOn; +static uint16_t gThrustSweep; // counts down to zero; per-frame freq +static uint16_t gCrashTicks; // noise burst countdown (impact "bang") +static uint16_t gCrashScreamSweep; // voice-1 descending sweep during crash anim +static uint16_t gNoiseRng; // xorshift state for crash noise + + +static void armSfx(uint16_t freq, uint8_t atten, uint16_t ticks); +static void silenceSfx(void); + + +void stAudioInit(void) { + (void)jlAudioInit(); + gThrustOn = false; + gSfxTicksLeft = 0u; + gThrustSweep = 0u; + gCrashTicks = 0u; + gCrashScreamSweep = 0u; + gNoiseRng = 0xACE1u; // arbitrary nonzero xorshift seed + jlAudioVoice(ST_SFX_VOICE, 0u, 0u); + jlAudioVoice(ST_THRUST_VOICE, 0u, 0u); +} + + +void stAudioShutdown(void) { + silenceSfx(); + if (gThrustOn) { + jlAudioVoice(ST_THRUST_VOICE, 0u, 0u); + gThrustOn = false; + } + jlAudioStopMod(); + jlAudioShutdown(); +} + + +// Called every host frame from spacetaxi.c's main loop (alongside +// jlAudioFrameTick which advances the mixer). Drives: +// - thrust sweep: per-frame freq decrement on ST_THRUST_VOICE +// mirroring the C64's $721B sweep +// - crash noise: per-frame xorshift -> freq jump on ST_SFX_VOICE +// approximating voice-3 noise +// - transient SFX countdown (pickup/dropoff/land tones) +void stAudioFrameTick(void) { + // Thrust voice has two modes: normal thrust (gThrustOn) sweeps + // freq down while held, OR crash scream sweep takes over when + // active (mutually exclusive -- the cab can't crash and thrust + // simultaneously). Crash scream wins if both are armed. + if (gCrashScreamSweep > 0u) { + gCrashScreamSweep--; + if (gCrashScreamSweep == 0u) { + jlAudioVoice(ST_THRUST_VOICE, 0u, 0u); + } else { + jlAudioVoice(ST_THRUST_VOICE, + (uint16_t)(gCrashScreamSweep * ST_THRUST_HZ_PER_TICK), + ST_THRUST_ATTEN); + } + } else if (gThrustOn && gThrustSweep > 0u) { + gThrustSweep--; + if (gThrustSweep == 0u) { + jlAudioVoice(ST_THRUST_VOICE, 0u, 0u); + } else { + jlAudioVoice(ST_THRUST_VOICE, + (uint16_t)(gThrustSweep * ST_THRUST_HZ_PER_TICK), + ST_THRUST_ATTEN); + } + } + // Noise-burst "bang" on the SFX voice, separate from the scream. + if (gCrashTicks > 0u) { + uint16_t pitchHz; + // xorshift LFSR -- 16-bit, period 65535. + gNoiseRng ^= (uint16_t)(gNoiseRng << 7); + gNoiseRng ^= (uint16_t)(gNoiseRng >> 9); + gNoiseRng ^= (uint16_t)(gNoiseRng << 8); + // Map random word to a noisy-feeling pitch range 100..700 Hz. + pitchHz = (uint16_t)(100u + (gNoiseRng % 600u)); + jlAudioVoice(ST_SFX_VOICE, pitchHz, ST_SFX_DEFAULT_ATTEN); + gCrashTicks--; + if (gCrashTicks == 0u) { + jlAudioVoice(ST_SFX_VOICE, 0u, 0u); + } + } else if (gSfxTicksLeft > 0u) { + gSfxTicksLeft--; + if (gSfxTicksLeft == 0u) { + silenceSfx(); + } + } +} + + +void stAudioPlayMusic(uint8_t musicId) { + // TODO: per-target music dispatch. Silent for now so the + // engine bring-up isn't blocked on writing per-platform music + // assets. + (void)musicId; +} + + +void stAudioStopMusic(void) { + jlAudioStopMod(); +} + + +// Engine thrust: starts a new freq-sweep burst on each rising edge. +// Mirrors the C64's $6A63 reset of $721B to $A0 -> phase handler +// decrements and writes to $D400/$D401 each tick. +void stAudioSfxThrust(bool on) { + if (on == gThrustOn) { + return; + } + gThrustOn = on; + if (on) { + gThrustSweep = ST_THRUST_SWEEP_INIT; + jlAudioVoice(ST_THRUST_VOICE, + (uint16_t)(gThrustSweep * ST_THRUST_HZ_PER_TICK), + ST_THRUST_ATTEN); + } else { + gThrustSweep = 0u; + jlAudioVoice(ST_THRUST_VOICE, 0u, 0u); + } +} + + +void stAudioSfxLand(void) { + armSfx(ST_SFX_LAND_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_LAND_TICKS); +} + + +void stAudioSfxPickup(void) { + armSfx(ST_SFX_PICKUP_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_PICKUP_TICKS); +} + + +void stAudioSfxDropoff(void) { + armSfx(ST_SFX_DROPOFF_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_DROPOFF_TICKS); +} + + +// Crash: kicks off TWO simultaneous events on the C64 (see header +// comment near ST_SFX_CRASH_TICKS): +// 1. impact "bang" -- noise burst on ST_SFX_VOICE +// 2. descending "scream" -- voice-1 freq sweep on ST_THRUST_VOICE +// Force gThrustOn false so the scream takes over the thrust voice +// cleanly even if the player was thrust-holding into the wall. +void stAudioSfxCrash(void) { + gThrustOn = false; + gCrashTicks = ST_SFX_CRASH_TICKS; + gCrashScreamSweep = ST_CRASH_SCREAM_INIT; + gSfxTicksLeft = 0u; +} + + +// ----- internal ----- + +static void armSfx(uint16_t freq, uint8_t atten, uint16_t ticks) { + jlAudioVoice(ST_SFX_VOICE, freq, atten); + gSfxTicksLeft = ticks; +} + + +static void silenceSfx(void) { + jlAudioVoice(ST_SFX_VOICE, 0u, 0u); + gSfxTicksLeft = 0u; +} diff --git a/stEngine.c b/stEngine.c new file mode 100644 index 0000000..33a1ea4 --- /dev/null +++ b/stEngine.c @@ -0,0 +1,340 @@ +// Space Taxi -- taxi physics. +// +// Faithful translation of the C64's $6032 per-frame physics routine +// (see MECHANICS.md "Per-frame algorithm" and "Velocity model +// (CORRECTED)"). Acceleration-based: stick deflection sets an INSTANT +// per-frame acceleration; gravity is a constant acceleration always +// added (Y, signed -- level K is anti-gravity); both feed a persistent +// velocity accumulator that integrates into position each frame. +// Release the stick and velocity persists -- the cab drifts. +// +// Edge handling MATCHES the C64's $6AED behaviour: vx reflects at +// column 23 going left or column 65 going right. The cab BOUNCES off +// the screen edges rather than stopping. +// +// Crash detection: any contact with a solid non-pad tile crashes the +// cab. Any contact with a pad-surface tile is a successful landing. +// The C64 game does the same via sprite-bg hardware collision plus +// the $7D75 trampoline predicate; we substitute explicit pad-vs-wall +// tile classification. +// +// Per-level physics templates ($7D8F-$7D96 in the C64; xAccel/yAccel/ +// xGrav/yGrav fields on StLevelT in the port) make every level feel +// different -- accel ranges $0E..$40, Y-gravity ranges $F9 (-7, +// anti-gravity on level K) to $06 (heavy on levels D/E/H/M/etc.). + +#include + +#include "spacetaxi.h" + +JOEYLIB_SEGMENT("STAXI") + +// Velocity safety clamp. With ST_SUBPIXEL = 256, ST_MAX_VX = 512 caps +// the cab at ~2 px/frame which is in line with the C64 max effective +// velocity (the C64's int16 accumulator integrates accel=14..64 over +// many frames; left uncapped it'd wrap, but real-game effective speeds +// stay in the hundreds-of-sub-units range). Earlier 127 capped at +// 0.5 px/frame, making the cab feel glued to molasses. +#define ST_MAX_VX 512 +#define ST_MAX_VY 512 + +// Initial downward velocity injected at crash. The C64 sets $714F = +// $03 and $714E = $03 at $6A4E-$6A53 -- a 16-bit vy of $0303 = 771 +// sub-units (~3 px/frame). The port's ST_MAX_VY caps the effective +// max, so we just start at that ceiling for a strong opening fall; +// gravity (the level's own yGrav) then integrates normally during +// the death anim. On anti-gravity levels (K = -7) the initial high +// downward velocity dominates for many frames before being slowed +// or reversed -- matches the C64's behavior on those levels. +#define ST_CRASH_FALL_VY ST_MAX_VY + +#define ST_TAXI_W_PX 24 +#define ST_TAXI_H_PX 24 + +// Death-animation duration. Matches the C64 sequence at $6A72/$6B24/ +// $6B4C: phase-1 keeps integrating Y velocity until the cab falls to +// the floor (row >= $DA = 218), then phase-2 walks the sprite cels +// $CC..$D1 with a rate that slows each step, then phase-3 holds for +// 70 frames before the finalize (DEC lives, blank sprite). 120 host +// frames here covers the full "fall + sit" sequence at the port's +// physics scale. The cab keeps falling under gravity during the +// countdown so the visual matches the original "watch it drop". +#define ST_CRASH_ANIM_FRAMES 120u + +// Edge reflection at the visible playfield edges. The C64's $6AED +// thresholds (col 23 left / col 65 in X-MSB high half) are in VIC +// sprite-X coordinates where X=24 is the left visible column -- so +// "col 23" means "cab leftedge at visible left edge". Our port has +// no VIC-X offset (X=0 IS the visible left edge), so the equivalent +// is "bounce when the cab tries to leave the visible playfield". + + +static bool isSolidAt(const StLevelT *level, int16_t px, int16_t py); +static bool onLandingPad(const StLevelT *level, int16_t px, int16_t py, uint8_t *outPad); +static void reflectAtEdges(StTaxiT *t); +static void respawnTaxi(StGameT *game); + + +static bool isSolidAt(const StLevelT *level, int16_t px, int16_t py) { + int16_t tx = (int16_t)(px / ST_TILE_PIXELS); + int16_t ty = (int16_t)(py / ST_TILE_PIXELS); + uint8_t tile; + + if (tx < 0 || tx >= (int16_t)ST_TILEMAP_W) { + return true; // off-screen sides treated as walls + } + if (ty < 0 || ty >= (int16_t)ST_PLAYFIELD_ROWS) { + return true; // top/bottom out-of-field treated as walls + } + tile = level->tilemap[ty * ST_TILEMAP_W + tx]; + // Tile-index convention (authored by the level designer): + // 0 = empty space + // 1..63 = solid (walls, platforms, structure) + // 64..127 = landing-pad surfaces (also solid for collision) + // 128+ = decorative non-solid (lights, signs, etc.) + return tile != 0u && tile < 128u; +} + + +static bool onLandingPad(const StLevelT *level, int16_t px, int16_t py, uint8_t *outPad) { + uint8_t i; + uint8_t tx; + uint8_t ty; + + tx = (uint8_t)(px / ST_TILE_PIXELS); + ty = (uint8_t)(py / ST_TILE_PIXELS); + for (i = 0u; i < level->padCount; i++) { + const StPadT *p = &level->pads[i]; + if (ty == p->tileY && tx >= p->tileX && tx < (uint8_t)(p->tileX + p->tileW)) { + if (outPad != NULL) { + *outPad = i; + } + return true; + } + } + return false; +} + + +static void reflectAtEdges(StTaxiT *t) { + // C64 $6AED behavior translated to the port's 0..319 visible + // playfield: when vx is negative and the cab leftedge reaches 0, + // negate vx. Same on the right when leftedge reaches (320 - W). + // The cab visually touches the screen edge and bounces back; + // matches the C64's "screen-edge bumper" behavior. Y has no + // bounce -- the cab just stops at top/bottom (no real level + // throws the cab against those edges). + int32_t maxX = ((int32_t)ST_TILEMAP_W * ST_TILE_PIXELS - ST_TAXI_W_PX) * ST_SUBPIXEL; + int32_t maxY = ((int32_t)ST_PLAYFIELD_ROWS * ST_TILE_PIXELS - ST_TAXI_H_PX) * ST_SUBPIXEL; + + if (t->x < 0 && t->vx < 0) { + t->vx = (int16_t)(-t->vx); + t->x = 0; + } + if (t->x > maxX && t->vx > 0) { + t->vx = (int16_t)(-t->vx); + t->x = maxX; + } + if (t->y < 0) { t->y = 0; t->vy = 0; } + if (t->y > maxY) { t->y = maxY; t->vy = 0; } +} + + +static void respawnTaxi(StGameT *game) { + StTaxiT *t = &game->taxi; + int32_t spawnX; + int32_t spawnY; + uint8_t i; + + spawnX = (int32_t)game->level.taxiSpawnTileX * ST_TILE_PIXELS * ST_SUBPIXEL; + spawnY = (int32_t)game->level.taxiSpawnTileY * ST_TILE_PIXELS * ST_SUBPIXEL; + t->x = spawnX; + t->y = spawnY; + t->vx = 0; + t->vy = 0; + t->landed = false; + t->onPad = 0xFFu; + t->thrusting = false; + // Boot any in-flight passenger out of the cab; a respawn doesn't + // keep the fare. Waiting passengers (still on a pad) survive. + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + if (game->passengers[i].active && game->passengers[i].onboard) { + game->passengers[i].active = false; + game->passengers[i].onboard = false; + } + } +} + + +void stEngineReset(StGameT *game) { + StTaxiT *t = &game->taxi; + const StLevelT *L = &game->level; + + memset(t, 0, sizeof(*t)); + t->x = (int32_t)L->taxiSpawnTileX * ST_TILE_PIXELS * ST_SUBPIXEL; + t->y = (int32_t)L->taxiSpawnTileY * ST_TILE_PIXELS * ST_SUBPIXEL; + t->facing = ST_DIR_RIGHT; + t->onPad = 0xFFu; + + // Delegated to stPassenger.c so the fare-cursor (gNextFareIdx) and + // the seed-spawn share one code path. Previously stEngineReset + // spawned fares[0] inline without advancing the cursor, which then + // caused spawnNextFare to re-spawn fare 0 instead of fare 1. + stPassengerReset(game); +} + + +void stEngineTick(StGameT *game) { + StTaxiT *t = &game->taxi; + const StLevelT *L = &game->level; + int32_t nx; + int32_t ny; + int16_t ax; + int16_t ay; + int16_t centerX; + int16_t feetY; + uint8_t landingPad; + + // Death animation: mirror the C64 sequence -- the cab keeps + // FALLING through phase 1 ($6A72) until it hits the floor, then + // sits there through phases 2/3 before respawn. No input accepted, + // no further crash checks (otherwise hitting the floor on the way + // down would re-trigger). Mute thrust input so applyInput's + // per-frame joystick read can't keep the SFX going. + // + // Uses the LEVEL's normal gravity during integration -- the C64 + // doesn't override gravity at crash, it just injects a high + // downward vy at crash entry and then lets normal physics run. + // On level K (anti-gravity = -7) the high initial vy dominates + // for many frames before being slowed/reversed; matches C64. + if (t->crashTicks > 0u) { + int32_t maxY = ((int32_t)ST_PLAYFIELD_ROWS * ST_TILE_PIXELS + - ST_TAXI_H_PX) * ST_SUBPIXEL; + + t->thrusting = false; + t->vy = (int16_t)(t->vy + (int16_t)L->yGrav); + if (t->vy > ST_MAX_VY) { t->vy = ST_MAX_VY; } + if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; } + t->y += t->vy; + if (t->y > maxY) { + t->y = maxY; + t->vy = 0; + } + t->crashTicks--; + if (t->crashTicks == 0u) { + if (game->lives > 0u) { + game->lives--; + } + if (game->lives == 0u) { + game->state = ST_STATE_GAME_OVER; + } else { + respawnTaxi(game); + } + } + return; + } + + // Step 1: instant acceleration from stick. + ax = (int16_t)((int16_t)t->thrustDx * (int16_t)L->xAccel); + ay = (int16_t)((int16_t)t->thrustDy * (int16_t)L->yAccel); + if (!t->thrusting) { + ax = 0; + ay = 0; + } + + // Step 2: integrate accel + per-level gravity into velocity. + // Gravity is int8 signed so a level can pull upward (level K). + t->vx = (int16_t)(t->vx + ax + (int16_t)L->xGrav); + t->vy = (int16_t)(t->vy + ay + (int16_t)L->yGrav); + + if (t->thrusting) { + // Thrust-flame cel cycling. The asset (genPlaceholderArt.py) + // authors 3 thrust-flame variants at cels 1..3. Advance the + // counter every frame; cel selection in stRender divides by + // ST_THRUST_CEL_TICKS so the visible animation runs at ~10 Hz. + t->thrustFrame++; + if (t->thrustFrame >= (uint8_t)(ST_THRUST_CEL_COUNT * ST_THRUST_CEL_TICKS)) { + t->thrustFrame = 0u; + } + } else { + t->thrustFrame = 0u; + } + + // Safety clamp on the velocity accumulator -- without this a long + // fall under gravity (or sustained one-way thrust against no + // collision) lets vx/vy wrap int16_t and reverse sign. + if (t->vx > ST_MAX_VX) { t->vx = ST_MAX_VX; } + if (t->vx < -ST_MAX_VX) { t->vx = -ST_MAX_VX; } + if (t->vy > ST_MAX_VY) { t->vy = ST_MAX_VY; } + if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; } + + nx = t->x + t->vx; + ny = t->y + t->vy; + + // Wall collision (X-axis): sample the cab's leading edge at three + // Y rows (top, middle, bottom). If any sample hits a solid cell, + // undo X movement and zero vx so the cab stops against the wall. + { + int16_t leadX = (t->vx > 0) + ? (int16_t)((nx >> ST_SUBPIXEL_SHIFT) + ST_TAXI_W_PX - 1) + : (int16_t)(nx >> ST_SUBPIXEL_SHIFT); + int16_t topY = (int16_t)(t->y >> ST_SUBPIXEL_SHIFT); + int16_t midY = (int16_t)(topY + ST_TAXI_H_PX / 2); + int16_t botY = (int16_t)(topY + ST_TAXI_H_PX - 1); + if (isSolidAt(L, leadX, topY) || + isSolidAt(L, leadX, midY) || + isSolidAt(L, leadX, botY)) { + nx = t->x; + t->vx = 0; + } + } + + // Y-axis collision. The C64 game has no velocity threshold: any + // sprite-vs-background contact crashes the cab unless the cab is + // touching a pad surface (in which case it's a successful landing + // regardless of descent speed). We do the same via tile lookup. + feetY = (int16_t)((ny >> ST_SUBPIXEL_SHIFT) + ST_TAXI_H_PX - 1); + centerX = (int16_t)((nx >> ST_SUBPIXEL_SHIFT) + ST_TAXI_W_PX / 2); + + if (t->vy > 0) { + if (isSolidAt(L, centerX, feetY)) { + if (onLandingPad(L, centerX, feetY, &landingPad)) { + ny = (int32_t)(feetY / ST_TILE_PIXELS) * ST_TILE_PIXELS * ST_SUBPIXEL + - (int32_t)ST_TAXI_H_PX * ST_SUBPIXEL; + t->vx = 0; + t->vy = 0; + t->landed = true; + t->onPad = landingPad; + } else { + stAudioSfxCrash(); + // Enter the falling-death state. Zero vx so the cab + // doesn't drift sideways during the fall, and inject + // a high downward vy matching the C64's $6A4E-$6A53 + // setup (which writes $0303 = 771 sub-units into + // $714E/$714F regardless of pre-impact state). The + // crashTicks gate at the top of stEngineTick handles + // gravity integration + respawn from here. + t->vx = 0; + t->vy = ST_CRASH_FALL_VY; + t->thrusting = false; + t->crashTicks = ST_CRASH_ANIM_FRAMES; + } + } else { + t->landed = false; + t->onPad = 0xFFu; + } + } else if (t->vy < 0) { + // Ascending: head hits ceiling? Stop vertical motion, leave + // horizontal alone so the cab can drift along the underside. + int16_t headY = (int16_t)(ny >> ST_SUBPIXEL_SHIFT); + if (isSolidAt(L, centerX, headY)) { + ny = t->y; + t->vy = 0; + } + t->landed = false; + } + + t->x = nx; + t->y = ny; + reflectAtEdges(t); +} diff --git a/stHud.c b/stHud.c new file mode 100644 index 0000000..b235d01 --- /dev/null +++ b/stHud.c @@ -0,0 +1,79 @@ +// Space Taxi -- HUD (score / lives / level / current-fare strip). +// +// Lives in the bottom 3 tile-rows (y = 176..199) below the playfield. +// Renders textual score / lives / level name / current fare via the +// font asset (loaded by stRender). No fuel meter: the C64 original +// has no fuel mechanic. The strip at $DBA2-$DBCB in the original game +// is a per-frame Y-velocity status indicator ($6419), not a fuel bar. + +#include +#include + +#include "spacetaxi.h" + +JOEYLIB_SEGMENT("STAXI") + + +void stHudDraw(jlSurfaceT *stage, const StGameT *game) { + char buf[32]; + char livesBuf[ST_MAX_PADS + 1]; + uint8_t i; + + // Wipe the HUD band with the level's border color (C64 $7D00 -> $D020). + // Most canonical levels set both border and bg to 0 (black), so this + // looks identical to the previous hardcoded ST_HUD_BG_COLOR for them. + jlFillRect(stage, + 0, + (int16_t)(ST_HUD_ROW * ST_TILE_PIXELS), + SURFACE_WIDTH, + (int16_t)(ST_HUD_ROW_COUNT * ST_TILE_PIXELS), + game->level.borderColor); + + // 4-digit score with a separator after the thousands digit, matching + // the C64 HUD template at $43B1 ('___ . __'). The format puts the + // ones digit at position 3 and uses '.' as a thousands marker. Range + // is 0..9999; beyond that we wrap (the C64 BCD can't exceed 9999 + // either). + snprintf(buf, sizeof(buf), "%04lu.", + (unsigned long)(game->score % 10000ul)); + stRenderDrawText(stage, 0u, (uint8_t)ST_HUD_ROW, buf); + + // Lives indicator: graphic-ish "cabs remaining" -- one 'O' per life, + // up to 9. C64 shows this as filled glyphs in color RAM ($DBDC etc). + for (i = 0u; i < 9u && i < game->lives; i++) { + livesBuf[i] = 'O'; + } + livesBuf[i] = '\0'; + stRenderDrawText(stage, 7u, (uint8_t)ST_HUD_ROW, livesBuf); + + // Level name right-aligned in the 40-col HUD row. + { + uint8_t nameLen = (uint8_t)strlen(game->level.name); + uint8_t col = (nameLen < ST_TILEMAP_W) + ? (uint8_t)(ST_TILEMAP_W - nameLen) + : 0u; + stRenderDrawText(stage, col, (uint8_t)ST_HUD_ROW, game->level.name); + } + + // Active fare display: where the current passenger wants to go + // (or "PAD x WANTS y" when one is still waiting to be picked up). + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + const StPassengerT *p = &game->passengers[i]; + if (p->active && p->onboard && + p->destPad < game->level.padCount) { + char dest = (char)game->level.pads[p->destPad].letter; + snprintf(buf, sizeof(buf), "FARE %c", dest); + stRenderDrawText(stage, 0u, (uint8_t)(ST_HUD_ROW + 1u), buf); + break; + } + if (p->active && !p->onboard && + p->currentPad < game->level.padCount) { + char src = (char)game->level.pads[p->currentPad].letter; + char dest = (p->destPad < game->level.padCount) + ? (char)game->level.pads[p->destPad].letter : '?'; + snprintf(buf, sizeof(buf), "PAD %c WANTS %c", src, dest); + stRenderDrawText(stage, 0u, (uint8_t)(ST_HUD_ROW + 1u), buf); + break; + } + } +} diff --git a/stLevel.c b/stLevel.c new file mode 100644 index 0000000..be604a6 --- /dev/null +++ b/stLevel.c @@ -0,0 +1,144 @@ +// Space Taxi -- level loader. +// +// Reads a level .dat file produced by `tools/spacetaxi/mkLevel.py` +// (see assets/levels/format.md for the byte layout). +// +// A level file is small (~2-3 KB raw, plus a name + per-pad config), +// loaded once per scene change. Read fully into RAM; tilemap + colormap +// stay inside the StLevelT struct for the life of the game state. + +#include +#include +#include + +#include "spacetaxi.h" + +JOEYLIB_SEGMENT("STAXI") + +// STL2 adds the per-level physics templates (xAccel/yAccel/xGrav/yGrav) +// and the full VIC color block ($7D00-$7D08). Drops the patience byte +// from each fare entry (Space Taxi proper has no patience timeout). +#define ST_LEVEL_MAGIC0 'S' +#define ST_LEVEL_MAGIC1 'T' +#define ST_LEVEL_MAGIC2 'L' +#define ST_LEVEL_MAGIC3 '2' + + +static bool readByte(FILE *fp, uint8_t *out) { + int c = fgetc(fp); + if (c == EOF) { + return false; + } + *out = (uint8_t)c; + return true; +} + + +static bool readBytes(FILE *fp, void *dst, size_t n) { + return fread(dst, 1, n, fp) == n; +} + + +bool stLevelLoad(StLevelT *out, const char *path) { + FILE *fp; + uint8_t hdr[4]; + uint8_t nameLen; + uint8_t i; + size_t cells; + + memset(out, 0, sizeof(*out)); + + fp = fopen(path, "rb"); + if (fp == NULL) { + return false; + } + + if (!readBytes(fp, hdr, 4) || + hdr[0] != ST_LEVEL_MAGIC0 || hdr[1] != ST_LEVEL_MAGIC1 || + hdr[2] != ST_LEVEL_MAGIC2 || hdr[3] != ST_LEVEL_MAGIC3) { + fclose(fp); + return false; + } + + if (!readByte(fp, &nameLen) || nameLen >= sizeof(out->name)) { + fclose(fp); + return false; + } + if (!readBytes(fp, out->name, nameLen)) { + fclose(fp); + return false; + } + out->name[nameLen] = '\0'; + + if (!readByte(fp, &out->tileBankId) || + !readByte(fp, &out->musicId) || + !readByte(fp, &out->bgColor) || + !readByte(fp, &out->borderColor) || + !readByte(fp, &out->taxiSpawnTileX) || + !readByte(fp, &out->taxiSpawnTileY)) { + fclose(fp); + return false; + } + + { + uint8_t xGravByte; + uint8_t yGravByte; + if (!readByte(fp, &out->xAccel) || + !readByte(fp, &out->yAccel) || + !readByte(fp, &xGravByte) || + !readByte(fp, &yGravByte)) { + fclose(fp); + return false; + } + out->xGrav = (int8_t)xGravByte; + out->yGrav = (int8_t)yGravByte; + } + if (!readByte(fp, &out->bgColor1) || + !readByte(fp, &out->bgColor2) || + !readByte(fp, &out->bgColor3) || + !readByte(fp, &out->spriteMc0) || + !readByte(fp, &out->spriteMc1) || + !readByte(fp, &out->sprite0Color) || + !readByte(fp, &out->sprite1Color)) { + fclose(fp); + return false; + } + + if (!readByte(fp, &out->padCount) || out->padCount > ST_MAX_PADS) { + fclose(fp); + return false; + } + for (i = 0u; i < out->padCount; i++) { + if (!readByte(fp, &out->pads[i].letter) || + !readByte(fp, &out->pads[i].tileX) || + !readByte(fp, &out->pads[i].tileY) || + !readByte(fp, &out->pads[i].tileW)) { + fclose(fp); + return false; + } + } + + if (!readByte(fp, &out->fareCount) || out->fareCount > ST_MAX_FARES) { + fclose(fp); + return false; + } + for (i = 0u; i < out->fareCount; i++) { + if (!readByte(fp, &out->fares[i].spawnPad) || + !readByte(fp, &out->fares[i].destPad)) { + fclose(fp); + return false; + } + } + + cells = (size_t)ST_TILEMAP_W * (size_t)ST_PLAYFIELD_ROWS; + if (!readBytes(fp, out->tilemap, cells) || + !readBytes(fp, out->colormap, cells)) { + fclose(fp); + return false; + } + + fclose(fp); + return true; +} + + diff --git a/stPassenger.c b/stPassenger.c new file mode 100644 index 0000000..e3df99d --- /dev/null +++ b/stPassenger.c @@ -0,0 +1,217 @@ +// Space Taxi -- passenger AI. +// +// A passenger has three states: +// 1. Waiting on a pad (active, !onboard) - cycles walk animation. +// Boards when the taxi lands on their pad. +// 2. In the cab (active, onboard) - invisible; followed by +// the taxi until it touches the destination pad. +// 3. Done (!active) - waiting to be replaced +// by the next fare slot. +// +// When a passenger lands at their destination, the next fare in the +// level's fareCount is spawned. When the level's fares are exhausted, +// the level is done. +// +// Scoring is flat-rate per the C64 original ($43B9 BCD blob added at +// success-stage 6 / $6742): 500 points per delivered fare. The earlier +// "tip by patience" formula was a port-side invention. + +#include + +#include "spacetaxi.h" + +JOEYLIB_SEGMENT("STAXI") + +#define ST_PASSENGER_W_PX 16 +#define ST_PASSENGER_H_PX 16 +#define ST_PASSENGER_H_TILES (ST_PASSENGER_H_PX / ST_TILE_PIXELS) +#define ST_WALK_FRAMES 4u +#define ST_WALK_TICKS_PER_CEL 6u + +// Verified via emulator trace of $4354 (BCD-add) with the $43B9 blob +// against an all-blank HUD: the only HUD position modified is index 3 +// (the ones-digit of the integer part in the C64's DDDD.DD layout), +// where digit '5' gets added. So a basic fare delivered = +5 score. +// See stuff/spacetaxi/trace.py and the run output for proof. +// +// Two other blobs exist but their callers haven't been traced: +// $43C1 = +95 (called from $5D03 after a per-passenger counter +// decrement -- looks like a bonus event) +// $43C9 = +50 (called from $5C82 in the $5BBB-dispatched anim -- +// possibly a "fare-on-takeoff" or "tip" bonus) +#define ST_FARE_SCORE 5u + + +// Per-port passenger queue cursor. Reset by stPassengerReset whenever a +// new level (or restart-after-game-over) needs to begin from fare 0. +static uint8_t gNextFareIdx; +static uint8_t gWalkTick; + + +static uint8_t effectiveFareTarget(const StGameT *game); +static void spawnNextFare(StGameT *game); + + +// Apply the title-screen fare-count selector ($7213 in the C64) as an +// upper cap on this level's fare count. The level data sets the +// maximum (e.g. 8 for a long delivery chain); player picks 1..4 on +// the title to control session length. The lesser of the two wins. +static uint8_t effectiveFareTarget(const StGameT *game) { + uint8_t levelMax = game->level.fareCount; + uint8_t sel = game->fareTarget; + if (sel == 0u) { + return levelMax; + } + return (levelMax < sel) ? levelMax : sel; +} + + +static void spawnNextFare(StGameT *game) { + const StFareT *fare; + const StPadT *pad; + StPassengerT *slot; + uint8_t i; + + if (gNextFareIdx >= effectiveFareTarget(game)) { + return; + } + // Find an idle slot. + slot = NULL; + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + if (!game->passengers[i].active) { + slot = &game->passengers[i]; + break; + } + } + if (slot == NULL) { + return; + } + fare = &game->level.fares[gNextFareIdx++]; + if (fare->spawnPad >= game->level.padCount) { + return; + } + pad = &game->level.pads[fare->spawnPad]; + + slot->active = true; + slot->onboard = false; + slot->currentPad = fare->spawnPad; + slot->destPad = fare->destPad; + slot->walkPhase = 0u; + slot->walkDir = 0u; + slot->x = (int16_t)(pad->tileX * ST_TILE_PIXELS); + // Pad's tileY is the landing-surface row (top of the pad block). + // Place the passenger so their feet sit on that surface -- + // top-left Y is (tileY - passengerHeightInTiles) * tilePixels. + slot->y = (int16_t)((pad->tileY - ST_PASSENGER_H_TILES) * ST_TILE_PIXELS); +} + + +void stPassengerReset(StGameT *game) { + uint8_t i; + + gNextFareIdx = 0u; + gWalkTick = 0u; + + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + game->passengers[i].active = false; + } + // Seed the first fare for the level. Subsequent fares spawn on + // successful drop-off via spawnNextFare inside stPassengerTick. + spawnNextFare(game); +} + + +void stPassengerTick(StGameT *game) { + StPassengerT *p; + StTaxiT *t = &game->taxi; + uint8_t i; + bool atDest; + + // Walk-cel cycling: advance every ST_WALK_TICKS_PER_CEL host frames + // for all waiting passengers in lockstep. Also step horizontally + // one pixel per cel advance so passengers walk back-and-forth + // across their pad. The C64 game's passenger AI was never fully + // traced -- this is a simplification giving each waiting fare + // some idle motion instead of standing in place. + if (++gWalkTick >= ST_WALK_TICKS_PER_CEL) { + gWalkTick = 0u; + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + StPassengerT *p = &game->passengers[i]; + const StPadT *pad; + int16_t padXMin; + int16_t padXMax; + if (!p->active || p->onboard) { + continue; + } + p->walkPhase = (uint8_t)((p->walkPhase + 1u) % ST_WALK_FRAMES); + if (p->currentPad >= game->level.padCount) { + continue; + } + pad = &game->level.pads[p->currentPad]; + padXMin = (int16_t)(pad->tileX * ST_TILE_PIXELS); + padXMax = (int16_t)((pad->tileX + pad->tileW) * ST_TILE_PIXELS + - ST_PASSENGER_W_PX); + if (padXMax <= padXMin) { + continue; // pad too narrow to walk on + } + if (p->walkDir == 0u) { + p->x++; + if (p->x >= padXMax) { + p->x = padXMax; + p->walkDir = 1u; + } + } else { + p->x--; + if (p->x <= padXMin) { + p->x = padXMin; + p->walkDir = 0u; + } + } + } + } + + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + p = &game->passengers[i]; + if (!p->active) { + continue; + } + + if (!p->onboard) { + // Board if the taxi has landed on their pad. + if (t->landed && t->onPad == p->currentPad) { + p->onboard = true; + stAudioSfxPickup(); + } + } else { + // Onboard: deliver to destination pad. + if (p->destPad >= game->level.padCount) { + p->active = false; + continue; + } + atDest = t->landed && + t->onPad < game->level.padCount && + t->onPad == p->destPad; + if (atDest) { + game->score += ST_FARE_SCORE; + stAudioSfxDropoff(); + p->active = false; + spawnNextFare(game); + } + } + } + + // If every fare is exhausted AND no active passenger remains, + // the level is complete. + if (gNextFareIdx >= effectiveFareTarget(game)) { + bool anyActive = false; + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + if (game->passengers[i].active) { + anyActive = true; + break; + } + } + if (!anyActive) { + game->state = ST_STATE_LEVEL_DONE; + } + } +} diff --git a/stRender.c b/stRender.c new file mode 100644 index 0000000..d89b3e9 --- /dev/null +++ b/stRender.c @@ -0,0 +1,1033 @@ +// Space Taxi -- tile bank + sprite rendering. +// +// Loads native (.tbk / .spr) assets at startup via jlTileBankLoad +// and jlSpriteBankLoad. Tile bytes are per-target planar; sprite +// data is cross-target chunky 4bpp (the Phase 11 walker reads +// chunky and c2p's inline at draw time). Per-frame work: save- +// under for moving sprites, draw, restore-under next frame. The +// static tilemap is committed once per scene change. +// +// Source PNGs live in assets/ and are baked at build time by +// tools/assetbake/assetbake.py: +// font.png -> font.tbk (1000-tile 40x25 glyph sheet) +// tiles/tbankN.png -> tiles/tbankN.tbk (256-tile playfield bank) +// sprites/sprites.png -> sprites/sprites.spr (9x3 grid of 3x3 +// 24x24 cels: row 0 taxi, row 1 passenger, row 2 flame) + +#include +#include +#include + +#include "spacetaxi.h" + +// All STAXI example sources share the STAXI load segment so the +// IIgs binary's _ROOT bank stays under 64 KB. No-op on other ports. +JOEYLIB_SEGMENT("STAXI") + +// Tile bank files are per-level: each level's .dat references a +// numeric bank id, and the host loads tiles/tbankN.tbk on demand +// (cached so re-entry on the same id is free). +// +// Per JoeyLib convention: each app installs into its own subdir +// of bin/, with runtime assets under DATA/. The DOS binary cwd's +// to the app dir when launched, so these paths are relative. +// DOS 8.3 filename limit: "tilebank0.tbk" is 9.3, fopen fails under +// DOSBox strict 8.3. Shortened to "tbank%u.tbk" (6.3) so all four +// targets can use the same filenames without per-platform aliasing. +#define ST_TILE_BANK_PATH_FMT "DATA/tiles/tbank%u.tbk" +#define ST_SPRITE_SHEET_PATH "DATA/sprites/sprites.spr" +#define ST_FONT_PATH "DATA/font.tbk" + +#define ST_TILE_BANK_MAX 256u + +// Font sheet layout: 320x200 indexed PNG = 40x25 grid of 8x8 glyphs, +// 1000 tiles total. asciiMap[c] packs the (col, row) location for +// ASCII c into a uint16_t; jlDrawText looks the tile up and pastes +// it as a transparent-on-color-0 glyph. +#define ST_FONT_COLS 40 +#define ST_FONT_ROWS 25 +#define ST_FONT_TILES_MAX (ST_FONT_COLS * ST_FONT_ROWS) + +// Sprite sheet is 9 cols x 3 rows of 24x24 (= 3x3 tile) cels. Row 0 +// is the taxi (4 cels used, rest blank), row 1 is the passenger (9 +// cels), row 2 is the flame (8 cels). Cells lay out left-to-right +// top-to-bottom in the .spr blob's cellCount = 27. +#define ST_SPRITE_SHEET_COLS 9 +#define ST_SPRITE_SHEET_CELS 27 +#define ST_SPRITE_TAXI_FIRST 0 +#define ST_SPRITE_PASS_FIRST 9 +#define ST_SPRITE_FLAME_FIRST 18 +// Taxi sprite: 24x24 px = 3 tiles wide x 3 tiles tall. The port's +// sprite asset (genPlaceholderArt.py) authors 4 cels: +// cel 0: idle cab (no thrust) +// cel 1: thrust frame A +// cel 2: thrust frame B +// cel 3: thrust frame C +// Index: thrusting ? (1 + thrustFrame/ST_THRUST_CEL_TICKS) : 0. The +// C64's $619B selects between LEFT-facing ($DC) and RIGHT-facing +// ($C0) cab bases with a 1-bit flicker on each, but the port's asset +// doesn't carry left/right variants -- left/right facing isn't +// visually reflected until the sheet adds them. +#define ST_TAXI_W_PX 24 +#define ST_TAXI_H_PX 24 +#define ST_TAXI_CEL_COUNT 4 + +// Flame placeholder fallback (only used if real sprite-2 cel is +// missing): a small bright rectangle below the cab while thrusting. +// $6D6A in the asm positions sprite 2 at (taxi_col - 2, taxi_row); +// the real cels are extracted at runtime as flameCels[0..7]. +#define ST_FLAME_W_PX 8 +#define ST_FLAME_H_PX 8 +#define ST_FLAME_OFFSET_X_PX ((ST_TAXI_W_PX / 2) - (ST_FLAME_W_PX / 2)) +#define ST_FLAME_OFFSET_Y_PX (ST_TAXI_H_PX - 2) + +// Real flame sprite cels (24x24, from raw.bin sprite ptrs in $6DB0 +// table). Indexed by direction-mask -> cel via kFlameCelByDirMask +// below. $6D6A's parity bit ($716C) flickers the flame off every +// other frame. +#define ST_FLAME_CEL_W_PX 24 +#define ST_FLAME_CEL_H_PX 24 +#define ST_FLAME_CEL_COUNT 8 + +// Passenger: 24x24 (C64 hardware sprite size is 24x21; we pad 3 rows +// transparent at the bottom in the asset extraction to keep a square +// cell). Cels: +// 0/1 = walk-LEFT alternation ($C4 / $C5) for the title walk +// 2-7 = boarding sequence ($C6 / $C7 / $C8 / $C9 / $CA / $CB) per +// $67A6 incrementing gSpr1PtrShadow from $C6 toward $CC. +#define ST_PASSENGER_W_PX 24 +#define ST_PASSENGER_H_PX 24 +#define ST_PASSENGER_CEL_COUNT 9 +#define ST_PASSENGER_CEL_WALK0 0 +#define ST_PASSENGER_CEL_WALK1 1 +#define ST_PASSENGER_CEL_BOARD0 2 /* maps to C64 ptr $C6 */ +#define ST_PASSENGER_CEL_SPARKLE 8 /* C64 ptr $D9 -- 3rd sparkle cel */ + +#define ST_SPRITE_BACKUP_BYTES (((ST_TAXI_W_PX >> 1) + 4) * ST_TAXI_H_PX) + + +typedef struct { + jlTileT tiles[ST_TILE_BANK_MAX]; + bool tileValid[ST_TILE_BANK_MAX]; + uint8_t currentBankId; // which tbank%u.tbk is loaded (0xFF = none) + bool bankLoaded; + jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT]; + jlSpriteT *flameCels[ST_FLAME_CEL_COUNT]; + jlSpriteBackupT flameBackup; + uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES]; + bool flameHasBackup; + jlSpriteT *passengerCels[ST_PASSENGER_CEL_COUNT]; + jlSurfaceT *fontSurface; // glyph surface, built from font.tbk at load + uint16_t asciiMap[128]; + bool fontReady; + jlSpriteBackupT taxiBackup; + uint8_t taxiBackupMem[ST_SPRITE_BACKUP_BYTES]; + jlSpriteBackupT passengerBackup[ST_MAX_PASSENGERS]; + uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES]; + bool taxiHasBackup; + bool passengerHasBackup[ST_MAX_PASSENGERS]; + // Tilemap repaint gating: the static playfield art only needs to + // be blitted to the stage once per scene change, not every frame. + // Per-frame full repaint blows the per-frame budget on emulated + // 386 in DOSBox and exposes tearing as the paint races the raster. + bool tilemapDirty; + const StLevelT *lastLevel; +} StRenderStateT; + + +static StRenderStateT gRender; + + +static bool loadTileBank(uint8_t bankId); +static bool loadSpriteSheet(void); +static bool loadFontSheet(jlSurfaceT *stage); +static void buildAsciiMap(void); +static void destroySprites(void); + +// ---- Title intro + demo -------------------------------------------------- +// +// Faithful to the C64 sequence (see disassembly $4A03 et al): +// +// Stage 2 ($4A17): wait $5A=90 frames running $66B7 (sparkle/effect) +// before the passenger appears. +// Stage 3 ($4A24): passenger sprite walks horizontally toward the cab, +// one tick per $715D countdown. $6D0D moves $7176 +// (passenger col) +/- 2 each tick based on relative +// pad-hover X, toggling sprite cel via $7161 parity. +// Advances when passenger col == $28 (= 40, at cab). +// Stage 4 ($4A45): JMP $67A6 (transition). +// Stage 5 ($4A48): forces gInputDirMask = $01 (UP). Calls flameSpriteUpdate. +// Cab climbs until gTaxiRow < $14 = 20, then silences +// voice 3 and advances. This is the takeoff. +// Stage 6+: physicsTick now sources its input from the script at +// $0902 via $48F2. The recorded demo plays. +// +// Single cab, single passenger. The 7-sprite init at $4555-$456F just +// parks all hardware sprite slots at (col $AA, row $8C) so they don't +// flash garbage when the title is first shown. + +// Recorded (input_mask, frame_duration) pairs at C64 $0902. Played by +// $48F2 once the title intro reaches the demo phase. +static const uint8_t kTitleDemoScript[256] = { + 0x80, 0x17, 0x88, 0x0A, 0x80, 0x0A, 0x84, 0x04, 0x85, 0x03, 0x81, 0x03, 0x80, 0x0A, 0x88, 0x04, + 0x80, 0x01, 0x81, 0x0E, 0x80, 0x0D, 0x82, 0x02, 0x80, 0x0A, 0x81, 0x09, 0x85, 0x07, 0x84, 0x05, + 0x80, 0x02, 0x88, 0x05, 0x8A, 0x04, 0x82, 0x03, 0x80, 0x05, 0x85, 0x10, 0x81, 0x08, 0x89, 0x02, + 0x88, 0x0E, 0x8A, 0x03, 0x88, 0x06, 0x81, 0x07, 0x80, 0x12, 0x82, 0x05, 0x80, 0x04, 0x81, 0x11, + 0x80, 0x04, 0x82, 0x04, 0x86, 0x09, 0x84, 0x02, 0x95, 0x07, 0x85, 0x04, 0x81, 0x01, 0x80, 0x43, + 0x81, 0x0A, 0x84, 0x05, 0x86, 0x03, 0x82, 0x06, 0x80, 0x07, 0x81, 0x09, 0x80, 0x04, 0x88, 0x0B, + 0x80, 0x09, 0x84, 0x04, 0x80, 0x09, 0x81, 0x01, 0x89, 0x04, 0x80, 0x08, 0x85, 0x04, 0x81, 0x01, + 0x80, 0x05, 0x81, 0x15, 0x80, 0x0C, 0x82, 0x02, 0x86, 0x07, 0x80, 0x05, 0x81, 0x09, 0x80, 0x13, + 0x84, 0x01, 0x85, 0x03, 0x80, 0x06, 0x88, 0x0B, 0x80, 0x16, 0x81, 0x0A, 0x80, 0x01, 0x88, 0x04, + 0x80, 0x0C, 0x81, 0x03, 0x85, 0x02, 0x84, 0x03, 0x85, 0x0C, 0x81, 0x01, 0x80, 0x0D, 0x82, 0x07, + 0x80, 0x06, 0x81, 0x02, 0x89, 0x0C, 0x88, 0x02, 0x80, 0x05, 0x82, 0x04, 0x80, 0x0A, 0x81, 0x04, + 0x85, 0x01, 0x84, 0x04, 0x80, 0x0D, 0x81, 0x05, 0x89, 0x0E, 0x81, 0x01, 0x80, 0x11, 0x84, 0x17, + 0x80, 0x05, 0x81, 0x04, 0x80, 0x19, 0x81, 0x03, 0x89, 0x01, 0x88, 0x0E, 0x80, 0x07, 0x84, 0x05, + 0x80, 0x0C, 0x81, 0x0D, 0x80, 0x19, 0x88, 0x01, 0x80, 0x02, 0x88, 0x04, 0x80, 0x1B, 0x88, 0x0A, + 0x89, 0x0B, 0x81, 0x14, 0x80, 0x02, 0x82, 0x03, 0x80, 0x05, 0x84, 0x03, 0x85, 0x09, 0x84, 0x05, + 0x85, 0x05, 0x84, 0x02, 0x86, 0x08, 0x84, 0x01, 0x80, 0x01, 0x81, 0x03, 0x89, 0x12, 0x81, 0x08, +}; + +// C64 reference values straight from the asm at $4A03 dispatch + +// titleSpriteSetup ($4525) + sprite-init tables ($4994/$49AC). +#define ST_TITLE_STAGE_SPARKLE 2u // gDeathStage value, $4A17 +#define ST_TITLE_STAGE_WALK 3u // $4A24 +#define ST_TITLE_STAGE_HANDOFF 4u // $4A45 -> $67A6 +#define ST_TITLE_STAGE_LIFTOFF 5u // $4A48 +#define ST_TITLE_STAGE_DEMO 6u // intro over; demo physics +#define ST_TITLE_SPARKLE_FRAMES 0x5Au // $47CC LDA #$5A STA $473F +#define ST_TITLE_WALK_TICK_RELOAD 3u // $715E observed in raw.bin +// Walk target = gPadHoverXCol ($715F) = $28 set at $47D8. +// In C64 sprite-X coords. Visible col = sprite-X - 24. +#define ST_TITLE_PAD_HOVER_SX 0x28u +// Cab init from table $4994/$49AC: sprite-X=$28, sprite-Y=$84, ptr=$C1. +#define ST_TITLE_CAB_SX_INIT 0x28 +#define ST_TITLE_CAB_SY_INIT 0x84 +// Passenger init from table: sprite-X=$32 + frac=$01 -> X=$32+$100=306, +// sprite-Y=$84, ptr=$C7. +#define ST_TITLE_PASS_SX_INIT ((int16_t)0x132) // $32 + $100 +#define ST_TITLE_PASS_SY_INIT 0x84 +#define ST_TITLE_PASS_PTR_INIT 0xC7 // $49B4[1]: initial passenger sprite ptr +#define ST_TITLE_PASS_PTR_WALK_LO 0xC4 // $6D68: walk-LEFT cel A +#define ST_TITLE_PASS_PTR_WALK_HI 0xC5 // $6D69: walk-LEFT cel B +#define ST_TITLE_PASS_PTR_BOARD0 0xC6 // boarding sequence start +#define ST_TITLE_PASS_PTR_BOARDED 0xCC // $67B8: advance when ptr == $CC + +// $66D6 sparkle cel table -- cycled by $66B7 during stage 2. +// Each entry shown for $715E (= 3) frames; one full cycle = 12 frames. +static const uint8_t kTitleSparkleCels[4] = { 0xC6, 0xC7, 0xD9, 0xC7 }; + +// Map direction-mask (CIA1 PortA bits after EOR #$FF: bit0=UP bit1=DOWN +// bit2=LEFT bit3=RIGHT) to a flameCels[] index, mirroring the C64 +// table at $6DB0. Entries with no flame (no input, or invalid combos +// like UP+DOWN / LEFT+RIGHT / four-way) return -1. +static const int8_t kFlameCelByDirMask[16] = { + -1, /* 0 no input */ + 0, /* 1 UP -> $D8 */ + 1, /* 2 DOWN -> $D4 */ + -1, /* 3 UP+DOWN invalid */ + 2, /* 4 LEFT -> $D5 */ + 3, /* 5 UP+LEFT -> $D2 */ + 4, /* 6 DOWN+LEFT -> $D1 */ + -1, /* 7 */ + 5, /* 8 RIGHT -> $D7 */ + 6, /* 9 UP+RIGHT -> $D3 */ + 7, /* 10 DOWN+RIGHT -> $D6 */ + -1, -1, -1, -1, -1 +}; +// Stage-5 advance gate: $4A5F CMP #$14 BCC $4A64. Cab row in sprite-Y +// coords; visible row = sprite-Y - 50, so $14 = 20 = visible row -30. +#define ST_TITLE_CAB_TAKEOFF_SY 0x14 +// C64-to-visible conversion offsets (sprite hardware borders). +#define ST_SPRITE_X_OFFSET 24 +#define ST_SPRITE_Y_OFFSET 50 + +typedef struct { + uint8_t stage; // mirrors gDeathStage during intro + uint8_t introFrameCount; // mirrors $473F (stage 2 countdown) + uint8_t decayReload; // mirrors $715E + uint8_t decayTimer; // mirrors $715D (countdown per tick) + uint8_t passengerCelParity;// mirrors $7161 (toggled each walk step) + uint8_t sparkleIdx; // mirrors $716E (sparkle cel index 0..3) + uint8_t passengerPtr; // mirrors gSpr1PtrShadow $7198 + uint8_t cabPtr; // mirrors gSpr0PtrShadow $7197 + // Sprite positions in C64 sprite-X / sprite-Y space (16-bit because + // sprite-X is 9 bits when MSB latched). Convert with the OFFSET + // constants above when rendering. + int16_t cabSx; + int16_t cabSy; + int16_t passengerSx; + int16_t passengerSy; + bool passengerVisible; + bool cabVisible; + bool flameVisible; // sprite 2 from $6D6A flameSpriteUpdate + uint8_t flameDirMask; // mirrors $716A passed into $6DB0,X + uint8_t flameParity; // mirrors $716C; flickers flame off when 0 + // Demo (post-intro stage 6) playback state -- script from $0902. + uint16_t scriptIdx; + uint8_t scriptTimer; + int32_t demoX; // ST_SUBPIXEL fixed sub-pixel x + int32_t demoY; + int16_t demoVx; + int16_t demoVy; +} StTitleStateT; + +static StTitleStateT gTitle; + +static void titleReset(void); +static void titleTick(void); + + +// Public so stLevel.c can ask "give me the tile object for index N". +const jlTileT *stRenderTileForIndex(uint8_t tileIdx) { + if (!gRender.tileValid[tileIdx]) { + return NULL; + } + return &gRender.tiles[tileIdx]; +} + + +// Default 16-entry palette: matches the C64 VIC-II color register +// order so that an asset extracted with the C64 palette (via the +// extractFromDump.py tool) renders with the right colors here. The +// JoeyLib value format is $0RGB (4 bits per channel, top nibble +// unused). Index 0 black, 1 white, then C64 standard order. +static const uint16_t kDefaultPalette[16] = { + 0x0000, // 0 black + 0x0FFF, // 1 white + 0x0833, // 2 red + 0x06BB, // 3 cyan + 0x0839, // 4 purple + 0x05A4, // 5 green + 0x0438, // 6 blue + 0x0BC7, // 7 yellow + 0x0852, // 8 orange + 0x0540, // 9 brown + 0x0B66, // 10 light red + 0x0555, // 11 dark gray + 0x0777, // 12 mid gray + 0x09E8, // 13 light green + 0x076C, // 14 light blue + 0x09AA // 15 light gray +}; + +// Tile-index range -> placeholder fill color (used when no tile bank +// is loaded). Matches the engine's index-range convention. +static uint8_t placeholderColorFor(uint8_t tileIdx, uint8_t bgColor) { + // Used only when no tile bank is loaded -- debug-rendering. Slot + // indices reference kDefaultPalette above. + if (tileIdx == 0u) { return bgColor; } + if (tileIdx < 64u) { return 6u; } // walls -> blue + if (tileIdx < 128u) { return 5u; } // pads -> green + return 14u; // decor -> light blue +} + + +void stRenderInit(jlSurfaceT *stage) { + memset(&gRender, 0, sizeof(gRender)); + gRender.taxiBackup.bytes = gRender.taxiBackupMem; + gRender.flameBackup.bytes = gRender.flameBackupMem; + gRender.currentBankId = 0xFFu; // no bank loaded yet + for (uint8_t i = 0u; i < ST_MAX_PASSENGERS; i++) { + gRender.passengerBackup[i].bytes = gRender.passengerBackupMem[i]; + } + + // Install the default 16-color palette on slot 0 and route the + // whole screen through it. Subsequent asset palettes can override + // by writing into slot 1+ via jlPaletteSet / jlScbSetRange. + jlPaletteSet(stage, 0u, kDefaultPalette); + jlScbSetRange(stage, 0u, (uint16_t)(SURFACE_HEIGHT - 1u), 0u); + + // Tile bank is loaded on demand in stRenderLevel (per-level). + if (!loadSpriteSheet()) { + jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH); + } + if (!loadFontSheet(stage)) { + jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH); + } + buildAsciiMap(); + titleReset(); +} + + +void stRenderShutdown(void) { + destroySprites(); + if (gRender.fontSurface != NULL) { + jlSurfaceDestroy(gRender.fontSurface); + gRender.fontSurface = NULL; + } +} + + +void stRenderDrawText(jlSurfaceT *stage, uint8_t bx, uint8_t by, const char *s) { + if (!gRender.fontReady || gRender.fontSurface == NULL || s == NULL) { + return; + } + jlDrawText(stage, bx, by, gRender.fontSurface, gRender.asciiMap, s); +} + + +void stRenderLevel(jlSurfaceT *stage, const StLevelT *level) { + const jlTileT *tile; + uint8_t bx; + uint8_t by; + uint8_t tileIdx; + size_t i; + + // Swap to the level's tile bank if it's not already loaded. + // First call lands here and pulls tbank.tbk off disk. + if (!gRender.bankLoaded || gRender.currentBankId != level->tileBankId) { + (void)loadTileBank(level->tileBankId); + gRender.tilemapDirty = true; + } + // Scene change detected -> force a repaint. Otherwise this is a + // no-op (the level tilemap data is static between calls). + if (gRender.lastLevel != level) { + gRender.tilemapDirty = true; + gRender.lastLevel = level; + } + if (!gRender.tilemapDirty) { + return; + } + gRender.tilemapDirty = false; + + jlSurfaceClear(stage, level->bgColor); + + for (by = 0u; by < ST_PLAYFIELD_ROWS; by++) { + for (bx = 0u; bx < ST_TILEMAP_W; bx++) { + i = (size_t)by * ST_TILEMAP_W + bx; + tileIdx = level->tilemap[i]; + // tileIdx is uint8_t, naturally bounded to ST_TILE_BANK_MAX=256. + tile = gRender.tileValid[tileIdx] ? &gRender.tiles[tileIdx] : NULL; + if (tile != NULL) { + // C64-style per-cell coloring: tile bitmap is a fixed + // glyph from the charset, colormap[i] is the foreground + // color from color RAM ($D800). Empty tile (idx 0) + // still goes through here as bgColor on both fg/bg -- + // jlTileFill is left for the no-bank-loaded fallback. + uint8_t fg = (uint8_t)(level->colormap[i] & 0x0Fu); + jlTilePasteMono(stage, bx, by, tile, fg, level->bgColor); + } else { + jlTileFill(stage, bx, by, placeholderColorFor(tileIdx, level->bgColor)); + } + } + } +} + + +void stRenderLevelChanged(void) { + // Force the next stRenderLevel call to repaint. Needed because the + // dirty-cache compares lastLevel by pointer, but stLevelLoad + // overwrites *game.level in place -- the pointer stays equal and + // the cache would otherwise skip the new tilemap. + gRender.tilemapDirty = true; + gRender.lastLevel = NULL; +} + + + +// $4A03 dispatcher entry state, from $47C9 postInitPressFire: +// $47C7: A9 02 LDA #$02 +// $47C9: 8D 63 71 STA $7163 ; gDeathStage = 2 +// $47CC: A9 5A LDA #$5A +// $47CE: 8D 3F 47 STA $473F ; gIntroFrameCount +// $47D1: A9 00 LDA #$00 +// $47D3: 8D 60 71 STA $7160 ; gPadHoverXFrac +// $47D6: A9 28 LDA #$28 +// $47D8: 8D 5F 71 STA $715F ; gPadHoverXCol +// $47DB: AD 5E 71 LDA $715E ; decayReload (snapshot $03) +// $47DE: 8D 5D 71 STA $715D ; decayTimer +// Sprite positions from the $49BC table-loop, X=0..7: +// $4994 col[] = 28 32 00 86 9C A2 BA D4 +// $499C frac[] = 00 01 00 00 00 00 00 00 +// $49AC row[] = 84 84 00 E2 E2 E2 E2 E2 +// $49B4 ptr[] = C1 C7 00 DB DE DF E0 E1 +// Sprite 0 (cab) sprite-X = $28 = 40, sprite-Y = $84, ptr $C1. +// Sprite 1 (passenger) sprite-X = $32 + $100 = 306, sprite-Y = $84, +// ptr $C7. (frac=1 latches the X-MSB.) +static void titleReset(void) { + memset(&gTitle, 0, sizeof(gTitle)); + gTitle.stage = ST_TITLE_STAGE_SPARKLE; + gTitle.introFrameCount = ST_TITLE_SPARKLE_FRAMES; + gTitle.decayReload = ST_TITLE_WALK_TICK_RELOAD; + gTitle.decayTimer = ST_TITLE_WALK_TICK_RELOAD; + gTitle.passengerCelParity = 0u; + gTitle.passengerPtr = ST_TITLE_PASS_PTR_INIT; + gTitle.cabPtr = 0xC1; // sprite 0 init ptr + gTitle.cabSx = ST_TITLE_CAB_SX_INIT; + gTitle.cabSy = ST_TITLE_CAB_SY_INIT; + gTitle.passengerSx = ST_TITLE_PASS_SX_INIT; + gTitle.passengerSy = ST_TITLE_PASS_SY_INIT; + gTitle.cabVisible = true; + gTitle.passengerVisible = true; + gTitle.flameVisible = false; + // Demo state: initial script pos / pair-0 duration preload. + gTitle.scriptIdx = 0u; + gTitle.scriptTimer = kTitleDemoScript[1]; + gTitle.demoX = (int32_t)ST_TITLE_CAB_SX_INIT * ST_SUBPIXEL; + gTitle.demoY = (int32_t)ST_TITLE_CAB_SY_INIT * ST_SUBPIXEL; +} + + +// $6D0D passenger-walk: toggle $7161 cel parity, compute sign of +// (passenger_X - pad_hover_X), branch to walk-LEFT or walk-RIGHT +// helper. Both load gSpr1PtrShadow from a 2-entry ptr table indexed +// by the cel-parity bit, then advance the column by +/- 2. +// $6D30 LDX $7161 / LDA $6D66,X / STA $7198 (walk RIGHT) +// $6D33: $6D66 = $C2, $6D67 = $C3 +// $6D4B LDX $7161 / LDA $6D68,X / STA $7198 (walk LEFT) +// $6D4E: $6D68 = $C4, $6D69 = $C5 +// In the title intro the passenger starts at sprite-X 306 and walks +// LEFT toward $28, so the LEFT cel table ($C4 / $C5) is the one in +// use. We track the live ptr in gTitle.passengerPtr so the boarding +// stage can keep incrementing from wherever the walk left off. +static void titleStepPassenger(void) { + gTitle.passengerCelParity ^= 1u; + if (gTitle.passengerSx > (int16_t)ST_TITLE_PAD_HOVER_SX) { + gTitle.passengerSx -= 2; + gTitle.passengerPtr = (gTitle.passengerCelParity & 1u) + ? ST_TITLE_PASS_PTR_WALK_HI + : ST_TITLE_PASS_PTR_WALK_LO; + } else if (gTitle.passengerSx < (int16_t)ST_TITLE_PAD_HOVER_SX) { + gTitle.passengerSx += 2; + // Walk-RIGHT path uses $C2/$C3; not used by the title, but + // include it for completeness so any side-entry passenger + // would render correctly. + gTitle.passengerPtr = (gTitle.passengerCelParity & 1u) + ? 0xC3 + : 0xC2; + } +} + + +// $4A03 dispatcher. One iteration per host frame, exactly as the C64 +// runs $4A03 from $47E4 each iteration of the title intro loop. +static void titleTick(void) { + switch (gTitle.stage) { + case ST_TITLE_STAGE_SPARKLE: + // $4A17: JSR $66B7 / DEC $473F / BEQ -> INC $7163. + // $66B7 logic (per the asm): + // DEC $715D ; gIntroDecayTimer + // BEQ $66BD ; hit-zero -> cycle a cel + // RTS ; else keep current cel + // $66BD: LDA $715E STA $715D ; reload from gIntroDecayReload + // INC $716E ; sparkleIdx + // LDA $716E AND #$03 STA $716E ; mask 0..3 + // TAX + // LDA $66D6,X STA $7198 ; passenger ptr = sparkle table + // So the passenger ptr cycles $C6 / $C7 / $D9 / $C7 every reload + // frames during the 90-frame sparkle wait, AND the decay timer + // is left in the middle of its countdown when stage 3 takes + // over -- which is why the walk starts after 0-2 frames, not + // the full 3-frame reload. + if (gTitle.decayTimer > 0u) { + gTitle.decayTimer--; + } + if (gTitle.decayTimer == 0u) { + gTitle.decayTimer = gTitle.decayReload; + gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u); + gTitle.passengerPtr = kTitleSparkleCels[gTitle.sparkleIdx]; + } + if (gTitle.introFrameCount > 0u) { + gTitle.introFrameCount--; + } + if (gTitle.introFrameCount == 0u) { + gTitle.stage = ST_TITLE_STAGE_WALK; + } + break; + + case ST_TITLE_STAGE_WALK: + // $4A24: DEC $715D / BEQ -> reload from $715E + JSR $6D0D / + // then check (passenger col == $28) && ($7186 == 0) -> advance. + if (gTitle.decayTimer > 0u) { + gTitle.decayTimer--; + } + if (gTitle.decayTimer == 0u) { + gTitle.decayTimer = gTitle.decayReload; + titleStepPassenger(); + if (gTitle.passengerSx == (int16_t)ST_TITLE_PAD_HOVER_SX) { + gTitle.stage = ST_TITLE_STAGE_HANDOFF; + } + } + break; + + case ST_TITLE_STAGE_HANDOFF: + // $4A45: JMP $67A6. + // $67A6: DEC $715D / BEQ -> reload + INC $7198 (passenger + // sprite cel ptr). Advance when ptr == $CC. + if (gTitle.decayTimer > 0u) { + gTitle.decayTimer--; + } + if (gTitle.decayTimer == 0u) { + gTitle.decayTimer = gTitle.decayReload; + gTitle.passengerPtr++; + if (gTitle.passengerPtr >= ST_TITLE_PASS_PTR_BOARDED) { + // $67BD: STA $718F = 0 (pad hover off); INC $7163 to 5. + gTitle.passengerVisible = false; + gTitle.stage = ST_TITLE_STAGE_LIFTOFF; + } + } + break; + + case ST_TITLE_STAGE_LIFTOFF: + // $4A48: LDX #0; LDA #$FE; JSR $4113 (gTaxiRow[0] += -2). + // LDA #$01; STA gInputDirMask. LDA #$C0; STA gSpr0Ptr. + // JSR $6D6A (flame sprite update -- sprite 2 visible). + // LDA gTaxiRow[0]; CMP #$14; BCC $4A64. + gTitle.cabSy -= 2; + gTitle.cabPtr = 0xC0; + gTitle.flameDirMask = 0x01; // UP only + gTitle.flameVisible = true; + gTitle.flameParity ^= 1u; // $6D8B-$6D90 toggle on every call + if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) { + // $4A64-$4A6C: INC gDeathStage; silence voice 3. + gTitle.cabVisible = false; + gTitle.flameVisible = false; + gTitle.stage = ST_TITLE_STAGE_DEMO; + // Demo continues from wherever the cab is -- the C64 does + // NOT reset the cab position. Physics + gravity bring it + // back into view from the top. + gTitle.demoX = (int32_t)gTitle.cabSx * ST_SUBPIXEL; + gTitle.demoY = (int32_t)gTitle.cabSy * ST_SUBPIXEL; + gTitle.demoVx = 0; + gTitle.demoVy = (int16_t)(-2 * ST_SUBPIXEL); // takeoff momentum + } + break; + + case ST_TITLE_STAGE_DEMO: + default: + { + // Demo phase = physicsTick with $48F2 sourcing input from + // kTitleDemoScript. $48F2 advance pattern at $4903-$4923: + // DEC $4740; BNE return; -- still on current pair + // LDY #$03; LDA ($3F),Y; -- new timer from NEXT pair + // STA $4740; + // $3F += 2; -- advance to next pair + uint8_t mask; + int8_t dx; + int8_t dy; + int32_t maxX = (int32_t)(SURFACE_WIDTH - ST_TAXI_W_PX) * ST_SUBPIXEL; + int32_t maxY = (int32_t)(SURFACE_HEIGHT - ST_TAXI_H_PX) * ST_SUBPIXEL; + if (gTitle.scriptTimer > 0u) { + gTitle.scriptTimer--; + } + if (gTitle.scriptTimer == 0u) { + gTitle.scriptIdx = (uint16_t)((gTitle.scriptIdx + 2u) & 0xFFu); + gTitle.scriptTimer = + kTitleDemoScript[(gTitle.scriptIdx + 1u) & 0xFFu]; + if (gTitle.scriptTimer == 0u) { + gTitle.scriptTimer = 1u; + } + } + mask = kTitleDemoScript[gTitle.scriptIdx & 0xFFu]; + // CIA1 PortA bits (after EOR #$FF at $6043): + // bit0 = UP, bit1 = DOWN, bit2 = LEFT, bit3 = RIGHT, bit4 = FIRE. + dx = (int8_t)(((mask & 0x08u) ? 1 : 0) - ((mask & 0x04u) ? 1 : 0)); + dy = (int8_t)(((mask & 0x02u) ? 1 : 0) - ((mask & 0x01u) ? 1 : 0)); + // Level-A defaults for the demo: xAccel/yAccel = 14, yGrav = 1. + gTitle.demoVx = (int16_t)(gTitle.demoVx + dx * 14); + gTitle.demoVy = (int16_t)(gTitle.demoVy + dy * 14 + 1); + if (gTitle.demoVx > 512) gTitle.demoVx = 512; + if (gTitle.demoVx < -512) gTitle.demoVx = -512; + if (gTitle.demoVy > 512) gTitle.demoVy = 512; + if (gTitle.demoVy < -512) gTitle.demoVy = -512; + gTitle.demoX += gTitle.demoVx; + gTitle.demoY += gTitle.demoVy; + if (gTitle.demoX < 0) { gTitle.demoX = 0; gTitle.demoVx = 0; } + if (gTitle.demoX > maxX) { gTitle.demoX = maxX; gTitle.demoVx = 0; } + // Y can go negative (cab off-screen above) -- let it. Only + // clamp at the bottom. + if (gTitle.demoY > maxY) { gTitle.demoY = maxY; gTitle.demoVy = 0; } + gTitle.cabSx = (int16_t)(gTitle.demoX >> ST_SUBPIXEL_SHIFT); + gTitle.cabSy = (int16_t)(gTitle.demoY >> ST_SUBPIXEL_SHIFT); + // Only re-show the cab once it's back inside visible Y. + if (gTitle.cabSy >= (int16_t)ST_SPRITE_Y_OFFSET) { + gTitle.cabVisible = true; + } + // Flame: per $6D6A, on whenever any direction bit is set in the + // mask. Build the mask from dx/dy the same way the C64 reads + // CIA1 PortA: bit0=UP bit1=DOWN bit2=LEFT bit3=RIGHT. + { + uint8_t mask = 0u; + if (dy < 0) mask |= 0x01u; // UP + if (dy > 0) mask |= 0x02u; // DOWN + if (dx < 0) mask |= 0x04u; // LEFT + if (dx > 0) mask |= 0x08u; // RIGHT + gTitle.flameDirMask = mask; + gTitle.flameVisible = (mask != 0u); + if (gTitle.flameVisible) { + gTitle.flameParity ^= 1u; + } + } + break; + } + } +} + + +void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { + int16_t px; + int16_t py; + uint8_t taxiCel; + uint8_t i; + char buf[32]; + + // Title screen: render the title tilemap (extracted from the C64 + // game's screen RAM at $0400). The "SPACE TAXI" logo stays at + // its captured color -- the C64 ran a rainbow cycle here, but + // we keep it static so the per-frame logo repaint cost is zero. + if (game->state == ST_STATE_TITLE) { + // Restore the area saved under the prev-frame sprites BEFORE + // anything else paints. Without this, jlSpriteDraw leaves the + // old sprite pixels on the stage and the cab/passenger smear + // across the screen as they move ("5 cabs then erase" was the + // titleCycleTick periodic tilemap re-blit fighting the + // accumulated trails). + // C64 sprite priority: sprite 0 (cab) > 1 (passenger) > 2 (flame). + // Draw order each frame: flame, passenger, cab (lowest priority + // first). Restore is the reverse: cab, passenger, flame -- so + // overlapping saved underlays peel off in the right sequence. + if (gRender.taxiHasBackup) { + jlSpriteRestoreUnder(stage, &gRender.taxiBackup); + gRender.taxiHasBackup = false; + } + if (gRender.passengerHasBackup[0]) { + jlSpriteRestoreUnder(stage, &gRender.passengerBackup[0]); + gRender.passengerHasBackup[0] = false; + } + if (gRender.flameHasBackup) { + jlSpriteRestoreUnder(stage, &gRender.flameBackup); + gRender.flameHasBackup = false; + } + // stRenderLevel only re-paints the tilemap when lastLevel changed + // (e.g., transitioned from gameplay back to title) -- otherwise + // it's a fast no-op. + stRenderLevel(stage, &game->level); + // No "fares per game" overlay on the title: the C64 has a + // separate options/menu scene driven by $5295 (header text) + // and $52E1 / $533C (the "1 2 3 4" digit row at row 2 col 28 + // with the selected digit highlighted in color RAM at $D86C). + // The main title's screen RAM at $0400 contains no game-option + // text -- only "BY JOHN F. BUTCHER" credits and the + // UP=hiscore / DOWN=instructions / FIRE=begin joystick line. + // Options-menu scene is a separate state to be built later. + // Run the C64 title intro state machine (sparkle -> passenger + // walk -> taxi takeoff -> scripted demo) and draw whichever + // sprites are visible this frame. + titleTick(); + // sprite-X -> visible col = sprite-X - 24 (C64 border offset). + // sprite-Y -> visible row = sprite-Y - 50 (top border). + // Draw lowest priority first: flame (sprite 2), then passenger + // (sprite 1), then cab (sprite 0) on top. + if (gTitle.flameVisible && (gTitle.flameParity & 1u) && + gTitle.flameDirMask < 16u) { + int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask]; + if (cel >= 0 && gRender.flameCels[cel] != NULL) { + // $6D74-$6D82: flame sprite-X = cab sprite-X - 2. + // $6D85-$6D88: flame sprite-Y = cab sprite-Y. + int16_t fx = (int16_t)(gTitle.cabSx - 2 - ST_SPRITE_X_OFFSET); + int16_t fy = (int16_t)(gTitle.cabSy - ST_SPRITE_Y_OFFSET); + jlSpriteSaveAndDraw(stage, gRender.flameCels[cel], + fx, fy, &gRender.flameBackup); + gRender.flameHasBackup = true; + } + } + if (gTitle.passengerVisible) { + // Map live C64 sprite ptr -> sprite-sheet cel index. + // $C4..$CB -> cels 0..7 (walk-LEFT cels then boarding) + // $D9 -> cel 8 (3rd sparkle cel from $66D6) + int16_t cel; + if (gTitle.passengerPtr == 0xD9) { + cel = ST_PASSENGER_CEL_SPARKLE; + } else { + cel = (int16_t)gTitle.passengerPtr - 0xC4; + } + if (cel < 0) { + cel = 0; + } + if (cel >= ST_PASSENGER_CEL_COUNT) { + cel = ST_PASSENGER_CEL_COUNT - 1; + } + if (gRender.passengerCels[cel] != NULL) { + jlSpriteSaveAndDraw(stage, gRender.passengerCels[cel], + (int16_t)(gTitle.passengerSx - ST_SPRITE_X_OFFSET), + (int16_t)(gTitle.passengerSy - ST_SPRITE_Y_OFFSET), + &gRender.passengerBackup[0]); + gRender.passengerHasBackup[0] = true; + } + } + if (gTitle.cabVisible) { + // $4A54 sets gSpr0PtrShadow = $C0 (cab with landing gear + // RETRACTED) during takeoff. Stages 2-4 use the init ptr + // $C1 (gear extended). We track the live ptr in + // gTitle.cabPtr and map: $C1 -> cel 0, $C0 -> cel 1. + int16_t cel = (gTitle.cabPtr == 0xC0) ? 1 : 0; + if (gRender.taxiCels[cel] == NULL) { + cel = 0; + } + if (gRender.taxiCels[cel] != NULL) { + jlSpriteSaveAndDraw(stage, gRender.taxiCels[cel], + (int16_t)(gTitle.cabSx - ST_SPRITE_X_OFFSET), + (int16_t)(gTitle.cabSy - ST_SPRITE_Y_OFFSET), + &gRender.taxiBackup); + gRender.taxiHasBackup = true; + } + } + jlWaitVBL(); + jlStagePresent(); + return; + } + if (game->state == ST_STATE_GAME_OVER) { + jlSurfaceClear(stage, 0u); + stRenderDrawText(stage, 15u, 8u, "GAME OVER"); + snprintf(buf, sizeof(buf), "FINAL SCORE %06lu", + (unsigned long)game->score); + stRenderDrawText(stage, 12u, 11u, buf); + stRenderDrawText(stage, 9u, 16u, "PRESS SPACE TO RESTART"); + jlWaitVBL(); + jlStagePresent(); + return; + } + + // Restore prev-frame sprite backups FIRST so the static tilemap + // shows through where the taxi/passenger used to be. Then we'll + // save+draw at the new positions below. + if (gRender.taxiHasBackup) { + jlSpriteRestoreUnder(stage, &gRender.taxiBackup); + gRender.taxiHasBackup = false; + } + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + if (gRender.passengerHasBackup[i]) { + jlSpriteRestoreUnder(stage, &gRender.passengerBackup[i]); + gRender.passengerHasBackup[i] = false; + } + } + // Static tilemap commit (no-op after first frame in a scene). + stRenderLevel(stage, &game->level); + + // Re-draw HUD strip (cheap; just a few characters per cycle). + stHudDraw(stage, game); + + px = (int16_t)(game->taxi.x >> ST_SUBPIXEL_SHIFT); + py = (int16_t)(game->taxi.y >> ST_SUBPIXEL_SHIFT); + + // Cel = idle (0) when not thrusting, otherwise cycle through the + // three authored flame frames (1, 2, 3). Falls back to cel 0 if + // a thrust cel isn't authored. During the crash anim ($6B24/$6B4C + // in the C64) the original walks sprite-0 through death cels + // $CC..$D1 at a slowing rate; the port doesn't have those cels + // so the cab keeps its normal sprite as it falls -- the falling + // motion + crash SFX + voice-1 scream are the cue. + if (game->taxi.thrusting) { + taxiCel = (uint8_t)(1u + + (game->taxi.thrustFrame / ST_THRUST_CEL_TICKS)); + if (taxiCel >= ST_TAXI_CEL_COUNT) { + taxiCel = 0u; + } + } else { + taxiCel = 0u; + } + if (gRender.taxiCels[taxiCel] == NULL) { + taxiCel = 0u; + } + if (gRender.taxiCels[taxiCel] != NULL) { + // Save-under + draw. Backup is replayed at the start of next + // frame above to undraw cleanly. + jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py, + &gRender.taxiBackup); + gRender.taxiHasBackup = true; + } else { + // No sprite asset: fall back to a placeholder rect AND mark + // the tilemap dirty so the level repaints over us next frame. + // Use the level's per-level sprite0Color ($D027) so the cab + // tint matches the level palette. + jlFillRect(stage, px, py, ST_TAXI_W_PX, ST_TAXI_H_PX, + game->level.sprite0Color); + gRender.tilemapDirty = true; + // Flame placeholder (only when the cab sprite isn't authored, + // since cels 1..3 of the authored asset already include the + // flame baked in). C64 sprite 2 ($6D6A, writes $7177/$7199) + // is positioned at + // (taxi_col - 2, taxi_row), cel-indexed by $716A direction; + // we use sprite1Color ($D028) so per-level palette is honored. + if (game->taxi.thrusting) { + jlFillRect(stage, + (int16_t)(px + ST_FLAME_OFFSET_X_PX), + (int16_t)(py + ST_FLAME_OFFSET_Y_PX), + ST_FLAME_W_PX, + ST_FLAME_H_PX, + game->level.sprite1Color); + } + } + + // Passengers (waiting or being carried) + for (i = 0u; i < ST_MAX_PASSENGERS; i++) { + const StPassengerT *p = &game->passengers[i]; + jlSpriteT *cel; + if (!p->active || p->onboard) { + continue; // onboard passengers ride invisibly inside the cab + } + cel = gRender.passengerCels[p->walkPhase % ST_PASSENGER_CEL_COUNT]; + if (cel == NULL) { + jlFillRect(stage, p->x, p->y, + ST_PASSENGER_W_PX, ST_PASSENGER_H_PX, 8u); + gRender.tilemapDirty = true; + continue; + } + jlSpriteSaveAndDraw(stage, cel, p->x, p->y, + &gRender.passengerBackup[i]); + gRender.passengerHasBackup[i] = true; + } + + if (game->state == ST_STATE_LEVEL_DONE) { + int16_t midY = (int16_t)(10 * ST_TILE_PIXELS); + jlFillRect(stage, 0, midY, SURFACE_WIDTH, + (int16_t)(2 * ST_TILE_PIXELS), 0u); + stRenderDrawText(stage, 13u, 10u, "LEVEL COMPLETE"); + } + + jlWaitVBL(); + jlStagePresent(); +} + + +// ----- internal helpers ----- + +static bool loadTileBank(uint8_t bankId) { + uint16_t idx; + uint16_t loaded; + char path[64]; + + snprintf(path, sizeof(path), ST_TILE_BANK_PATH_FMT, (unsigned)bankId); + jlLogF("stRender: loadTileBank(%u) -> %s", (unsigned)bankId, path); + + // Reset the previous bank's validity bits so a smaller new bank + // doesn't inherit stale tiles past its end. + for (idx = 0u; idx < ST_TILE_BANK_MAX; idx++) { + gRender.tileValid[idx] = false; + } + gRender.bankLoaded = false; + gRender.currentBankId = bankId; + + // Native bake: jlTileBankLoad fread's per-target planar tile + // bytes straight into gRender.tiles[].pixels with no chunky <-> + // planar conversion. Replaces the JAS-load + jlSurfaceCreate + + // jlSurfaceBlit + per-tile jlTileSnap path, which was the + // dominant startup cost. + loaded = jlTileBankLoad(path, gRender.tiles, ST_TILE_BANK_MAX, + gRender.tileValid, NULL); + if (loaded == 0u) { + jlLogF("stRender: ! tile bank load failed (%s)", path); + return false; + } + jlLogF("stRender: tile bank loaded (%u tiles)", (unsigned)loaded); + gRender.bankLoaded = true; + return true; +} + + +static bool loadSpriteSheet(void) { + jlSpriteT *cels[ST_SPRITE_SHEET_CELS]; + uint16_t count; + uint16_t i; + + // .spr blob carries all 27 cels (9 cols x 3 rows of 3x3-tile + // chunky 4bpp blobs) in PNG reading order: row 0 taxi, row 1 + // passenger, row 2 flame. The padding slots between named + // sprites are loaded but never referenced; they leak ~1.7 KB + // at startup which is well below the cost of writing a + // free-loop here (extra code in _ROOT eats the IIgs cluster + // budget more than the heap fragments hurt). + memset(cels, 0, sizeof(cels)); + count = jlSpriteBankLoad(ST_SPRITE_SHEET_PATH, cels, ST_SPRITE_SHEET_CELS, NULL); + if (count == 0u) { + return false; + } + jlLogF("stRender: sprite sheet loaded (%u cels)", (unsigned)count); + + for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) { + gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i]; + } + for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) { + gRender.passengerCels[i] = cels[ST_SPRITE_PASS_FIRST + i]; + } + for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) { + gRender.flameCels[i] = cels[ST_SPRITE_FLAME_FIRST + i]; + } + return true; +} + + +static void destroySprites(void) { + uint8_t i; + for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) { + if (gRender.taxiCels[i] != NULL) { + jlSpriteDestroy(gRender.taxiCels[i]); + gRender.taxiCels[i] = NULL; + } + } + for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) { + if (gRender.passengerCels[i] != NULL) { + jlSpriteDestroy(gRender.passengerCels[i]); + gRender.passengerCels[i] = NULL; + } + } +} + + +static bool loadFontSheet(jlSurfaceT *stage) { + jlTileT *fontTiles; + uint16_t count; + uint16_t i; + uint16_t palette[16]; + + (void)stage; + // Load font.tbk into a temp tile array, then paste each glyph + // into a regular surface that jlDrawText can sample. ~32 KB of + // temp memory for the 1000-tile font; freed before returning. + fontTiles = (jlTileT *)malloc(sizeof(jlTileT) * ST_FONT_TILES_MAX); + if (fontTiles == NULL) { + return false; + } + count = jlTileBankLoad(ST_FONT_PATH, fontTiles, ST_FONT_TILES_MAX, + NULL, palette); + if (count == 0u) { + free(fontTiles); + return false; + } + gRender.fontSurface = jlSurfaceCreate(); + if (gRender.fontSurface == NULL) { + free(fontTiles); + return false; + } + // Font palette is authoritative for the font surface so jlDrawText + // reads back the authored colors. Stage palette is untouched. + jlPaletteSet(gRender.fontSurface, 0u, palette); + jlScbSetRange(gRender.fontSurface, 0, SURFACE_HEIGHT - 1, 0); + for (i = 0u; i < count; i++) { + uint8_t bx = (uint8_t)(i % ST_FONT_COLS); + uint8_t by = (uint8_t)(i / ST_FONT_COLS); + jlTilePaste(gRender.fontSurface, bx, by, &fontTiles[i]); + } + gRender.fontReady = true; + free(fontTiles); + return true; +} + + +// Build the ASCII -> (blockX | blockY << 8) lookup table used by +// jlDrawText. The font sheet was authored with each ASCII glyph at +// cell (ascii % 40, ascii / 40) (see assets/genPlaceholderArt.py), +// so the map is a direct computation. Control characters and DEL +// (0..31, 127) are marked TILE_NO_GLYPH so jlDrawText skips them. +static void buildAsciiMap(void) { + uint16_t i; + for (i = 0u; i < 128u; i++) { + if (i < 32u || i == 127u) { + gRender.asciiMap[i] = TILE_NO_GLYPH; + } else { + uint16_t col = (uint16_t)(i % ST_FONT_COLS); + uint16_t row = (uint16_t)(i / ST_FONT_COLS); + gRender.asciiMap[i] = (uint16_t)(col | (row << 8)); + } + } +}