From 6804baa6c132798dc9b01ec411d2b2543d5f68a8 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Wed, 27 May 2026 20:22:24 -0500 Subject: [PATCH] Optimized a bit better. Examples are a mess of broken crap. --- MECHANICS.md | 2134 +++++++++++++++++++++++++ PLAN.md | 141 ++ VERIFIED.md | 96 ++ assets/font.png | Bin 0 -> 1379 bytes assets/genPlaceholderArt.py | 269 ++++ assets/levels/format.md | 92 ++ assets/levels/title.txt | 142 ++ assets/sprites/sprites.png | Bin 0 -> 437 bytes assets/tiles/tbank0.png | Bin 0 -> 1879 bytes assets/tiles/tbank1.png | Bin 0 -> 1201 bytes assets/tiles/tbank2.png | Bin 0 -> 1237 bytes extractFromDump.py | 496 ++++++ generated/amiga/font.tbk | Bin 0 -> 32044 bytes generated/amiga/levels/level01.dat | Bin 0 -> 2037 bytes generated/amiga/levels/level02.dat | Bin 0 -> 2049 bytes generated/amiga/levels/level03.dat | Bin 0 -> 2061 bytes generated/amiga/levels/level04.dat | Bin 0 -> 2091 bytes generated/amiga/levels/level05.dat | Bin 0 -> 2085 bytes generated/amiga/levels/level06.dat | Bin 0 -> 2049 bytes generated/amiga/levels/level07.dat | Bin 0 -> 2061 bytes generated/amiga/levels/level08.dat | Bin 0 -> 2061 bytes generated/amiga/levels/level09.dat | Bin 0 -> 2073 bytes generated/amiga/levels/level10.dat | Bin 0 -> 2067 bytes generated/amiga/levels/level11.dat | Bin 0 -> 2061 bytes generated/amiga/levels/level12.dat | Bin 0 -> 2067 bytes generated/amiga/levels/level13.dat | Bin 0 -> 2091 bytes generated/amiga/levels/level14.dat | Bin 0 -> 2079 bytes generated/amiga/levels/level15.dat | Bin 0 -> 2037 bytes generated/amiga/levels/level16.dat | Bin 0 -> 2049 bytes generated/amiga/levels/level17.dat | Bin 0 -> 2079 bytes generated/amiga/levels/level18.dat | Bin 0 -> 2037 bytes generated/amiga/levels/level19.dat | Bin 0 -> 2061 bytes generated/amiga/levels/level20.dat | Bin 0 -> 2079 bytes generated/amiga/levels/level21.dat | Bin 0 -> 2061 bytes generated/amiga/levels/level22.dat | Bin 0 -> 2073 bytes generated/amiga/levels/level23.dat | Bin 0 -> 2049 bytes generated/amiga/levels/level24.dat | Bin 0 -> 2079 bytes generated/amiga/levels/title.dat | Bin 0 -> 2029 bytes generated/amiga/sprites/sprites.spr | Bin 0 -> 7820 bytes generated/amiga/tiles/tbank0.tbk | Bin 0 -> 8236 bytes generated/amiga/tiles/tbank1.tbk | Bin 0 -> 32044 bytes generated/amiga/tiles/tbank2.tbk | Bin 0 -> 32044 bytes generated/atarist/font.tbk | Bin 0 -> 32044 bytes generated/atarist/levels/level01.dat | Bin 0 -> 2037 bytes generated/atarist/levels/level02.dat | Bin 0 -> 2049 bytes generated/atarist/levels/level03.dat | Bin 0 -> 2061 bytes generated/atarist/levels/level04.dat | Bin 0 -> 2091 bytes generated/atarist/levels/level05.dat | Bin 0 -> 2085 bytes generated/atarist/levels/level06.dat | Bin 0 -> 2049 bytes generated/atarist/levels/level07.dat | Bin 0 -> 2061 bytes generated/atarist/levels/level08.dat | Bin 0 -> 2061 bytes generated/atarist/levels/level09.dat | Bin 0 -> 2073 bytes generated/atarist/levels/level10.dat | Bin 0 -> 2067 bytes generated/atarist/levels/level11.dat | Bin 0 -> 2061 bytes generated/atarist/levels/level12.dat | Bin 0 -> 2067 bytes generated/atarist/levels/level13.dat | Bin 0 -> 2091 bytes generated/atarist/levels/level14.dat | Bin 0 -> 2079 bytes generated/atarist/levels/level15.dat | Bin 0 -> 2037 bytes generated/atarist/levels/level16.dat | Bin 0 -> 2049 bytes generated/atarist/levels/level17.dat | Bin 0 -> 2079 bytes generated/atarist/levels/level18.dat | Bin 0 -> 2037 bytes generated/atarist/levels/level19.dat | Bin 0 -> 2061 bytes generated/atarist/levels/level20.dat | Bin 0 -> 2079 bytes generated/atarist/levels/level21.dat | Bin 0 -> 2061 bytes generated/atarist/levels/level22.dat | Bin 0 -> 2073 bytes generated/atarist/levels/level23.dat | Bin 0 -> 2049 bytes generated/atarist/levels/level24.dat | Bin 0 -> 2079 bytes generated/atarist/levels/title.dat | Bin 0 -> 2029 bytes generated/atarist/sprites/sprites.spr | Bin 0 -> 7820 bytes generated/atarist/tiles/tbank0.tbk | Bin 0 -> 8236 bytes generated/atarist/tiles/tbank1.tbk | Bin 0 -> 32044 bytes generated/atarist/tiles/tbank2.tbk | Bin 0 -> 32044 bytes generated/dos/font.tbk | Bin 0 -> 32044 bytes generated/dos/levels/level01.dat | Bin 0 -> 2037 bytes generated/dos/levels/level02.dat | Bin 0 -> 2049 bytes generated/dos/levels/level03.dat | Bin 0 -> 2061 bytes generated/dos/levels/level04.dat | Bin 0 -> 2091 bytes generated/dos/levels/level05.dat | Bin 0 -> 2085 bytes generated/dos/levels/level06.dat | Bin 0 -> 2049 bytes generated/dos/levels/level07.dat | Bin 0 -> 2061 bytes generated/dos/levels/level08.dat | Bin 0 -> 2061 bytes generated/dos/levels/level09.dat | Bin 0 -> 2073 bytes generated/dos/levels/level10.dat | Bin 0 -> 2067 bytes generated/dos/levels/level11.dat | Bin 0 -> 2061 bytes generated/dos/levels/level12.dat | Bin 0 -> 2067 bytes generated/dos/levels/level13.dat | Bin 0 -> 2091 bytes generated/dos/levels/level14.dat | Bin 0 -> 2079 bytes generated/dos/levels/level15.dat | Bin 0 -> 2037 bytes generated/dos/levels/level16.dat | Bin 0 -> 2049 bytes generated/dos/levels/level17.dat | Bin 0 -> 2079 bytes generated/dos/levels/level18.dat | Bin 0 -> 2037 bytes generated/dos/levels/level19.dat | Bin 0 -> 2061 bytes generated/dos/levels/level20.dat | Bin 0 -> 2079 bytes generated/dos/levels/level21.dat | Bin 0 -> 2061 bytes generated/dos/levels/level22.dat | Bin 0 -> 2073 bytes generated/dos/levels/level23.dat | Bin 0 -> 2049 bytes generated/dos/levels/level24.dat | Bin 0 -> 2079 bytes generated/dos/levels/title.dat | Bin 0 -> 2029 bytes generated/dos/sprites/sprites.spr | Bin 0 -> 7820 bytes generated/dos/tiles/tbank0.tbk | Bin 0 -> 8236 bytes generated/dos/tiles/tbank1.tbk | Bin 0 -> 32044 bytes generated/dos/tiles/tbank2.tbk | Bin 0 -> 32044 bytes mkstlevel/mkstlevel.c | 499 ++++++ per-level-data.txt | 26 + spacetaxi.c | 256 +++ spacetaxi.h | 252 +++ stAudio.c | 235 +++ stEngine.c | 340 ++++ stHud.c | 79 + stLevel.c | 144 ++ stPassenger.c | 217 +++ stRender.c | 1033 ++++++++++++ 112 files changed, 6451 insertions(+) create mode 100644 MECHANICS.md create mode 100644 PLAN.md create mode 100644 VERIFIED.md create mode 100644 assets/font.png create mode 100644 assets/genPlaceholderArt.py create mode 100644 assets/levels/format.md create mode 100644 assets/levels/title.txt create mode 100644 assets/sprites/sprites.png create mode 100644 assets/tiles/tbank0.png create mode 100644 assets/tiles/tbank1.png create mode 100644 assets/tiles/tbank2.png create mode 100644 extractFromDump.py create mode 100644 generated/amiga/font.tbk create mode 100644 generated/amiga/levels/level01.dat create mode 100644 generated/amiga/levels/level02.dat create mode 100644 generated/amiga/levels/level03.dat create mode 100644 generated/amiga/levels/level04.dat create mode 100644 generated/amiga/levels/level05.dat create mode 100644 generated/amiga/levels/level06.dat create mode 100644 generated/amiga/levels/level07.dat create mode 100644 generated/amiga/levels/level08.dat create mode 100644 generated/amiga/levels/level09.dat create mode 100644 generated/amiga/levels/level10.dat create mode 100644 generated/amiga/levels/level11.dat create mode 100644 generated/amiga/levels/level12.dat create mode 100644 generated/amiga/levels/level13.dat create mode 100644 generated/amiga/levels/level14.dat create mode 100644 generated/amiga/levels/level15.dat create mode 100644 generated/amiga/levels/level16.dat create mode 100644 generated/amiga/levels/level17.dat create mode 100644 generated/amiga/levels/level18.dat create mode 100644 generated/amiga/levels/level19.dat create mode 100644 generated/amiga/levels/level20.dat create mode 100644 generated/amiga/levels/level21.dat create mode 100644 generated/amiga/levels/level22.dat create mode 100644 generated/amiga/levels/level23.dat create mode 100644 generated/amiga/levels/level24.dat create mode 100644 generated/amiga/levels/title.dat create mode 100644 generated/amiga/sprites/sprites.spr create mode 100644 generated/amiga/tiles/tbank0.tbk create mode 100644 generated/amiga/tiles/tbank1.tbk create mode 100644 generated/amiga/tiles/tbank2.tbk create mode 100644 generated/atarist/font.tbk create mode 100644 generated/atarist/levels/level01.dat create mode 100644 generated/atarist/levels/level02.dat create mode 100644 generated/atarist/levels/level03.dat create mode 100644 generated/atarist/levels/level04.dat create mode 100644 generated/atarist/levels/level05.dat create mode 100644 generated/atarist/levels/level06.dat create mode 100644 generated/atarist/levels/level07.dat create mode 100644 generated/atarist/levels/level08.dat create mode 100644 generated/atarist/levels/level09.dat create mode 100644 generated/atarist/levels/level10.dat create mode 100644 generated/atarist/levels/level11.dat create mode 100644 generated/atarist/levels/level12.dat create mode 100644 generated/atarist/levels/level13.dat create mode 100644 generated/atarist/levels/level14.dat create mode 100644 generated/atarist/levels/level15.dat create mode 100644 generated/atarist/levels/level16.dat create mode 100644 generated/atarist/levels/level17.dat create mode 100644 generated/atarist/levels/level18.dat create mode 100644 generated/atarist/levels/level19.dat create mode 100644 generated/atarist/levels/level20.dat create mode 100644 generated/atarist/levels/level21.dat create mode 100644 generated/atarist/levels/level22.dat create mode 100644 generated/atarist/levels/level23.dat create mode 100644 generated/atarist/levels/level24.dat create mode 100644 generated/atarist/levels/title.dat create mode 100644 generated/atarist/sprites/sprites.spr create mode 100644 generated/atarist/tiles/tbank0.tbk create mode 100644 generated/atarist/tiles/tbank1.tbk create mode 100644 generated/atarist/tiles/tbank2.tbk create mode 100644 generated/dos/font.tbk create mode 100644 generated/dos/levels/level01.dat create mode 100644 generated/dos/levels/level02.dat create mode 100644 generated/dos/levels/level03.dat create mode 100644 generated/dos/levels/level04.dat create mode 100644 generated/dos/levels/level05.dat create mode 100644 generated/dos/levels/level06.dat create mode 100644 generated/dos/levels/level07.dat create mode 100644 generated/dos/levels/level08.dat create mode 100644 generated/dos/levels/level09.dat create mode 100644 generated/dos/levels/level10.dat create mode 100644 generated/dos/levels/level11.dat create mode 100644 generated/dos/levels/level12.dat create mode 100644 generated/dos/levels/level13.dat create mode 100644 generated/dos/levels/level14.dat create mode 100644 generated/dos/levels/level15.dat create mode 100644 generated/dos/levels/level16.dat create mode 100644 generated/dos/levels/level17.dat create mode 100644 generated/dos/levels/level18.dat create mode 100644 generated/dos/levels/level19.dat create mode 100644 generated/dos/levels/level20.dat create mode 100644 generated/dos/levels/level21.dat create mode 100644 generated/dos/levels/level22.dat create mode 100644 generated/dos/levels/level23.dat create mode 100644 generated/dos/levels/level24.dat create mode 100644 generated/dos/levels/title.dat create mode 100644 generated/dos/sprites/sprites.spr create mode 100644 generated/dos/tiles/tbank0.tbk create mode 100644 generated/dos/tiles/tbank1.tbk create mode 100644 generated/dos/tiles/tbank2.tbk create mode 100644 mkstlevel/mkstlevel.c create mode 100644 per-level-data.txt create mode 100644 spacetaxi.c create mode 100644 spacetaxi.h create mode 100644 stAudio.c create mode 100644 stEngine.c create mode 100644 stHud.c create mode 100644 stLevel.c create mode 100644 stPassenger.c create mode 100644 stRender.c 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 0000000000000000000000000000000000000000..6a55e577f78e58cc120c0bc91bc950e677f93d08 GIT binary patch literal 1379 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoPjD~;$#u6*%w%9-W(e>JaRqY!|Nq}%X_UTg zZ@2xl&;_0jhQ0gG6?KPbhC4Cr$V>_d2&ky2nDU^rBJ;@n`SXG5N5N0r{yQ1&y&X(ffb@A9VH&1$Z$KQ|N z{cBGAzMObo_NgeN52MYZ$xc6S)Yx^zOCNAA2x7?oXR9=ee?iIvfu#~&-#!K%F=pM# z^Emp^aVy8pgG{H^o*m&RA_ z-o4>5&)H+Ye$4l}Ys9}x_~QB5Q~K|3@BbUU;{*CMJO!ybM>v!Sy zFWzb~>>p-q?N?v5UB&#d%_3Ln51W`gI%D;7b{zWkuk`4JP_H%iPi3Zscy?RJPTjUX z_1LA(mwt7})~4k6?a4j2Z}Oyi-Nm=!L}C?^BNC5PT<-hq-x+vGA?Rr7)L%(#dS3gM zL`=C7IrYz{p3^)z*H-viOE8t*%6jH=E06WVCiZW(8QP97|GirCKDK^B_vi0#pQtq4 zm9^wfID5c;@{6mMenNS7j~u@@xhi(fmj%*`zlBA|nsezJZ`gCc#P)N-#n}A2`|V4P z?>F23XJzGE^EJkgN}`VK-*sf6|I>BtF7I-EC+E$|Ix_p}fv>9;$m@O&)HJ_YaXde3 zdZYH@u=$xEx2it)AA9I;ORChq-TKV;KF)q8wc%O4=EUg^I|Fu0{*3;1V87wLS$2$M drP>3xf3n(FvhbeZ7Tg0ez|+;wWt~$(695LND);~Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9411d664efe38ca7641ad1136c626de5fb75ec67 GIT binary patch literal 437 zcmV;m0ZRUfP)p0IL7)%tXup%vAsYs_yQ0-H26YL;(MHcQZ3HcXxNH|7HM)|GT@pmu}cn0003% zNklO zcri;`JW$Vq7YEciui1cmtq!xn>IOQ2me3;l>U3M>JWbRoGu8=_E|XZTFO@XKj7~v3 z4SFhO^)eN`iDY#W`M$+;23iXJT%kx#i0wv~o?ZLayc2vE>|NdzpJ!k>prJVL6uE>V3WBUYT;`E=MM@ za`QTJ)M}kblGrLN@p^mzgZKOSJkRqdd^3nnwvuAXVgLY1oSl{PuhRcRSm>7zOUDU* z#gK?6S^p}V&Hm;8x!BpIV*tQsaaLGT45Pvmk@Lz>G+Bkcb7F5+C1mI;%9VfP&zZqP zK~AHY?IVf%f`i*PL|5L-#b%T|w{l9f*{{Q^@L%s}ghacPL`HZLI~FK!l6o;o9(8%q z_;O*8&`;*qhZ*QFpEwa^=cG5YF*&OKGwQ;L&OXoi7;#=9CQDn1=C|hYZhT<$26Z!rzOIbDrB@M| zb784J<`E!k#yH&2cT_lX?t)=C6+)Z%AtB4!OnX@ch}e^F$Ft)lqv878sn0Dwv0apj zwl`rrga`Xr5#p^`ViBIT9Lk7ApM!0P%%X`uZOPIs^75c7E7pC~38Ea<-w_~{?VCr> z?aiTg*yGwmx+5$ZpF~!Vb zj{kv_eEnPocDZi0U=B_+cJK2|HQ7WW&5d+#`pf3jDnb*~Ug%ExMjoAVwy6CHW2RV7 z5sx|O?osbG%6f?gS_H%a3*n(ikwyEi2x4&2fgTmGgg-0|33FLX)wx8o|N9u}h~sYg za_e(1O9L4T2dntoGt1tLP}hv_`ll^02?TMSQa8Vj{kY*RJeXSP6k|uuwFCgQA~TE3 zcp(ds`BxZ zQC^S7R)bO*1A8`~>|czRMtKf0?y?*P_a_07^ylSpk6Rd^d8onISs4WhARjv5;5k1K z0e#o{UD$3ScU5-YXe|xJ+H`wA_85jab^CfRBD^e7e7mKA8aV&0;D=yCmN0dD^e~~P z%9l7#y&9w#TOSFuwJPTBnH*rtF;nx3fEGP4!zWk%++nLpczU*X%7#=EPoXwh8LrR? zh{+nk=fQXhVbarr$dg8ha%G{(Q76DWOxnvFlC5?!<^HymbjU$>FJ!NBRxW2c6|Gdy zTwII>%jg2-!XWI=;m5m2@9UA6iK3Rtd9-NC<{?HP?T^2g|N5u(y8XiqVZ%ho#2oTT zRT?Qk0_0zXhtK%3cv^au-&NA{H)QtZT&6~W|1*Wi-k zz-#%FHfUG#RA9+|)irU=v|cwQVDu@x>2_Q*rn8kCNI)HYxBqyveyV7ew*|SQ=yVqY z6x%XAe4bdECic*3lMZg*WMU;Z|i&<_@V*XJVTH{oEo&%qaf&YYwqBnFhc#e zdZCYgKpW;Hf-z0mVI~o|C>h}nJk-?jt7LY6){!ePhJT&D8$Z-&>&@_oo{y}ZdcTL9 zIVr%0Gs{C{*Acc3>4WB=JyXN=%$-B8f~FJ70(XH-zn;_wjTz{W(!zzzP}Px3z8Ss&&}4;9MP7rX@3A)ihkDj6BxXk%+j;?=0z}5PXY+(IW-Q+q z6(6GswLP^_4X+f6ypol%#u^y0yE z*2GsI5*gJmrZ$p06BrRTb=&bS2Zje9wW0!s1J(mB9%yhN3EEN0dW_NyGpE literal 0 HcmV?d00001 diff --git a/assets/tiles/tbank1.png b/assets/tiles/tbank1.png new file mode 100644 index 0000000000000000000000000000000000000000..58bed90952ea020c71ff9a039e9f9b17a2830154 GIT binary patch literal 1201 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoPjD~;$#u6*%w%9-W(e>JaRqY!|Nq}%X_UTg zZ@2xl&;_0jhQ0gG6?KPbhC4Cr$V>_d2&ky2nDU^rBJ;@n`SXG5N5N2wOnK|LOPm1$P{X-L13vWRc&dbrG(A_AFRxVyd-z*ZT{0 zQPuw*7Q{rBMN600KfG}9;MG^@HaYqArh)&Wigw*%zvE`HPoMEy%dNo0_g^nA-8E-% zUSq4EZ*+A-;%UVLg|iBn<#`MCEsN91?fDzOrrq$xYRPq_DnmP=T%E1zjj^S*cS zJ9_Gv=kt8nvN8OD*n>FZ$Jr|m1Z-435H*>>J@Nco-2)OSy06cjXP@EsI+;~(ZVy+C zUc&4{TlED`Hq?bRCfn57{=6V^F0C}G@%yRpca|Z`v~Mu$d@icwDu};*FOvB)gZ#P! zrF?6%6MhMBh8mJaRqY!|Nq}%X_UTg zZ@2xl&;_0jhQ0gG6?KPbhC4Cr$V>_d2&ky2nDU^rBJ;@n`SXG5N5N0=iZ7XccOH1b1>ff1q{fg}_nYeF74I8H!^)JfZDrWV{ z;d@$=)a4gJcjdeo-MWhZ&Aq+aHNWYU@n0uaCzhR2uo_8qc+WsrfJ;yEg2qs8JUU{#q>t4z> z&8@g^.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 0000000000000000000000000000000000000000..61466adc270807ba1ab822c89ab4e102643d0722 GIT binary patch literal 32044 zcmeIyJ!%_46u|Kjw#E`#ly*SDg-@`-U~CwcAX23aNk3qiM#89aT!_^AjI zigSZ>%A0ktv=;C6f&U`J&c}N*`f+;r>=j`PG>W%PiRu~Yd!zJxUCi+ z=MPU$5A&$C`^wH;g}SaorG-0btE7+G?sDA5tT($5+9GH^!5YA|W^8G+1~?LdI;kmQ zzPZg=<&)HxvIF!f#O$j&ne89Fk>5{G^TvC1er;NND?aA5w&vgF>U9e%BSZaq?6f=qMAR*%78milzV1%vGaq1KQw*JcFRwqD z@cgoUY`2@&&9+|~*u7h=jD_Rc#lo)d9+p^yk-fo2!4Ue|_B&e3vp|&h5#EdF*i{}9 zjlm+K@IG+90!i*KdCKd*Qhe-yc@^(J_2M8d=H2ysn%G4iaY`Qi8T+7#i5`#W94+Ua zVfC!0dUK>+X{<+CXEH~%OL~cRzK={xeN5#oXsN!5PD)zRn`oqZA=9~G1af&kc(BTj^iE}!go&qA-e&8Ozk4KJ{?F5P>)Vk|FuB**iTGP{%fTjNtnD^dPz<}qX z9`k5J7NJ#IT29k-qE`UWi@b&l(fV5EFjhQ79n7d_jPP>Sffwsz-T=zWrZZY!*Vc1L zB#f8pAU&>Uj%PbdEkIAoruwF>qmgiZGNv@El#?B!dPvM8n&F|oTCbMT6}17M`_0Fr zXsT4YmHHa+@Fi28{2J+%!lcKzJO(^&0l4TH6V*sD8gCGqxHKGK=y;G6x%eHCjSlUo zTAdU$-YG?=^Tk;YcrHdbuJ1EAH?>JUl$G||>u#HMzKQ0o^{sR6R;~5RWkvyuQ51JB JoA=IT^balW;4lCH literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level02.dat b/generated/amiga/levels/level02.dat new file mode 100644 index 0000000000000000000000000000000000000000..dd590ea5eb5564fbd06e953d4ce031ff00c944aa GIT binary patch literal 2049 zcmcJQ!BX2W5Qb%A+0iIDq!)VhGxQCBNl&@Ka3j-}S`D<^GSf~j3@^aBkIVkMl2iXoYn%Q%Ay14l4s}oZcWof?s z80q^j(kL53xM`12cO=)go1^@|JyG%BbUeOWe*+A9A46S`2dK&ZXn_wQ+-WOQ>mV>Z zhr9cPPH}BV80lxUoa6|E4*Iq1NTtPa+7BRfm}`l8K8k~5^6OFyab?*MP&#oGr$uq< zzkGoXBcrOxAA&&$1xY(WnW|mLimG?fMhniXLEB4J%vXehjHk0w;Q?tBhK4x zSoOa9{r+p9jOrhSyT7{~XXEO42*~ua_LUmaD2a@llFE}N3zL})xkMFwr+Df)*`Y3zeIg4KHzw;(31cdel5cXka+FT0%W6`NBVZ_ogGe9{X}>RzMNyojBRSq5C8vXfq&h4z8O5;> zyhFeI`A~sQd1TEog02olCxFn#T^*aW z@3Y_g*5rdvXyeq18yUMTx@IOs+jZpimwlTV*Gj!$>+ailQtB1?W9jy6`qBh|8`Gq# z6?71Ipsa=tMsF}Od#_37gP&2QSEZuXX|}nbJevl(={*Wsf}zwmK=00ct}41~ESJkO zsx#8oJ}+y#>wvfK=Nj|cc}mX1zSh}o zg*%0P^^pX&XN}AmMbY@_C;RLdu@UZ6)H==T5uHv3I!Sx6tV}Fq9LrP)vBya9&NVD( O1FHHzzDP&{ZF3mqdO{Y>rrFLlW8t~BOTxU zzVMml(fcZQIr@;lsUIEw&=s;gU2|pop%vR)J*5v7&()@n4!_vLQoWb&9X{bl9}b_C zdP5r=j*f8VV_dJ-`$#JRzbIV%TrA9Le-RX*Ug@*20YRX}rfHIt%%O-A0P$NA=XfMU z2-F-;%7U>#lSRBQ(JfL$dt}0k6NC5?c{>JlFZv%kaWK$iT!pM9KXozgeSdfmTyG527=)`Q8SLG9a&hdp?ex!?U%rFW@-y? zht2XN&01mS#VF?LNj+@wTy6R=gQ`bd^E{}i4~I`G2v9jdS%RPycka3tK)=sE3lj&} N+5(7fmVsXBv*%*Z`nmuB literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level05.dat b/generated/amiga/levels/level05.dat new file mode 100644 index 0000000000000000000000000000000000000000..0b5268db5db70334c723d27503561b3f8bb46a7f GIT binary patch literal 2085 zcmeHHOHRWu6m;x5u2msqP!+Mu8MpzT6p*?rV#OaRRAO5R)MJ*yd3Yb&ahg(f1)I(y zapsNZjbD>auE)n_T-{XTvJyhr1L=k$hm|yWZcp5iIxPxy=7#FLI8>E$YEbbDa`rx(5u1J&H8QcPJ1~F_skQwLA}weQ z|2qL`!P*sFECw~+k)-EPZTBUKfN)#r1nS7k`;ukb51U!beyw=Gg#>)}Itq|fphxlq z%f>JAlmT@dhIw53a|9us9&{{AuE{x&r9o0(+Ts1co^{b~lMd7yb<~~mHM)?G57}(T z`6X#7BF_(ZaE1ECeBKkwelDaV5$LHe`ge7$KIlg=ybjlXF4UQ7;L`SEi!?IokZtFq z2YWfJ(oDIX+8m70G7H~c`JZdfbXW^ z`;fcdH0r(8sx=6tEFe{%NIpJD?GiB?9IjqkB4eh6j4VbuY%B@E9;Ls3H4l>G+SYp zay%9A0@b2bEiX_A^>``qM5E}AOL*;^9U#wZ6%AQ{`}RD0%48?bW659(8+b@os>IR0 zW#4ll_e-l~3!+jwF(*o8)+_;H3e?4>R3IoLF{Ii3T34UF8DrPV5y1(fwaK75+Sbk!fdL*ViN literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level07.dat b/generated/amiga/levels/level07.dat new file mode 100644 index 0000000000000000000000000000000000000000..b0114fa9167ed673c8310908fcc5fe29656606cf GIT binary patch literal 2061 zcma)6$xZ@65bbHEX$k7VctB61pWq+3pcif$KY#;}xLrsze7&!huI?TXDMC*zuU@_C zAw0j#ZsKhEI-PY-&bi|=e?Dq?;dXcu?%GMXzrF|$SEpfmc@~0e8t(^#wsp%x zt*dOg$dW#pfnbmBb-%V89;WP+T*V(T@_VbRi&1=2Uij9#T7+%=kxg+ zz7>J&Z>#Xz(5Ps)4B9`L0@?*zb`&)1QcZH=q{wh2g+aYaeKUX)j=K7|rK;#4>I>gA zv4ZqgR`KFRpX&H%H~F+7&)W|qHHnZ|HTYAEdkiW zSr5?AkV4?dEVKeLM4k&gDTm1;oNzD^$P2opTfil^t)!*0R{BgY7@SaUeqL5nx)sad zi_)vMOR-ia;oTULgjaV-NhJUo@o}W5QHg`H!-o(^fLR=lN~i;lK?x^CqqFizT~TYPIx zJc^?{_>z!RWzF0p89sTg5D}?(DM=Y+IrtG;wa_#ndhd^t#V{V{D#~J%4KyiP2;S%F EFLM3%j{pDw literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level08.dat b/generated/amiga/levels/level08.dat new file mode 100644 index 0000000000000000000000000000000000000000..18842ba8879a530d4f766d9a7925bd63ca43b0ab GIT binary patch literal 2061 zcmcgsL2?r@5R^C4E}PtP+uIgT;0F*)aqta_7Z4?o02il7`78Nsx@R=fC=OJG16{7& zndw$*G_w8a^X|>G+kV;Z){g-=AC@JI2!?4K)7#gl@!h(_i}PiCKQ3__Ph$$3%`lvt z#2E4su7IOrUsyZzT>fh0WwuD)p23<%x{yVe(z@v$TFA(yFVW@vD{Cvxp!!|rd_%W? z+1Is7KbQ7?j(A4*jWy!?Q=Ma^!+Zzy)?CMmF}+T*j`B&lI8313)VjKqq2rKpJ44TS z?x7pB&**3CaiRZJ=cid?PCeHQF;G%Tddf2*{VC7#_~DaVpS&o(zRuga;BYv6$fz{j zBi#Sq@1@#i$*%!)l}$&OfTLncg9HCf1Tzr0z(Admh{yx-hEB72<_3c$_&>50MTfMI zUN?)5Zc2ygZ@Ow3sM8LxaXklifM2QWc5KfM1D!aq59<#R(^+&91?vw+0GEh}x&WP6 z@F6#A9FT;+A3`FW}{^AO-?P+c*c)laR#!}{jUdvqCpt~_sSMkgb>klRKNSjTXaM#s+ UN@r)OvY-{LCOZ<}{Hizh5wwgJ>;M1& literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level09.dat b/generated/amiga/levels/level09.dat new file mode 100644 index 0000000000000000000000000000000000000000..28f3d58a35e574cde26ec4cb6963a580d3880d7f GIT binary patch literal 2073 zcmd6o+iKfD5QgQISF$&dK#HN4#*ff9=)oxk-!=5QjoWRKp25&!2(kO*{r{O+?K-ij z3#Igzy}O$EG>4UBzdT<&Y8UfY^TqT<1)aLdgQ~{DwN2AL_Mv{_PwJRB6af2h?PW!c0B*wS>jC$Tl!1z0C zI@P=fa40Pv7?0$K-cHc#HxktU-$(q|cSpAIt2Xi1`#5x;H;3GhKfG`8Q-8l-{z<<# z$nqKApF0mFQ>T8fJ6GO-n=8fn3T@(2WYcna9k0}aG5KU)7!|s_jB{OKT(8$>v}FCj za`k<+5^GtNT-kX8n>YdO3;y0hGULd za*m`ypbK7&U5VsWjGBX`Rr1P=Y?5#fCwz(|9H9LiDj?{}`cgWZHA^)H1X&+_6Vi|} zjrQiPM#;|^k)*i$^|A7?ln0Ev*29s4G^p(^P!W63RX38uee2GdxJa&9;NI<5nH|nH zM)S%z%0ao34McHw`?VBSnow2(F0ybbl6iB95|yzPEU!LJqEYi3*Vyxn|4u9GSmKZq u8@VRnqc2yJ-Z4Z>OK(O(Lz5p2eCwPWCAI$OD0|^n(dtu6oV|B0dw&5?_4|MT literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level10.dat b/generated/amiga/levels/level10.dat new file mode 100644 index 0000000000000000000000000000000000000000..c67d49ce045734271a9ca94932d5b5942a109ffa GIT binary patch literal 2067 zcmeH{%}&EG49DBFS*a$3KmxH{=riyJd~^r28;BbSm=q>HFOz^BxOec-IC1PcO%Zzl zd}y4%e~#_6+xf-xu$b1D^|U%mlBC>Gqfw&4Q^jB~ILfy3#YR4T@3o;a9i3_|3Bv1Ajg@H4Dq- za$-Pf?H%03+hXBVx+3faIF9Pb3b07jy8O)&8GwI|)?}b8I~)g*=WXfsv<>S*hyrk) zHQI(8#vFhT!paWEde>Q#OV;3A2PNEJr${!zmno70NA9yedv;Y7A literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level11.dat b/generated/amiga/levels/level11.dat new file mode 100644 index 0000000000000000000000000000000000000000..635f9020d30f1ee71ed2d9ccb4303ce142817766 GIT binary patch literal 2061 zcmd6n$xg#C5Qg2d5J;%hMH<5<5qD~iHhfh#5seM3AV4go--tH*!hK1i;hr6DFg z#(wnwiF;o%>9YCgIab)w^impi11B4|?o%3ZlPQbIh6|hJIRO;4rCd=5mveOxU$8s) zdlhG37!}`~d!o0{1CZZKe&XDt$Q#bBsKfhMy)WoK2%WH@#)U>@U7eHvo#vRX%?L@ z7}V30D~&M6Sb|t7>|lwwoVYB9mWnYXIdfPdjxqG8C@pp>T&!1v6*;v6M{^*@1C=NF ztAqkw1~@r4#c)k&@F3y7cELy^=q+>>6XxhaDphOo;|%j;Sq_TfWDKBVEx0NNV8D14 zTqv+AD#CtFP{!JvjpTg;+5}?i+juTX`8*sL`nlig=R@gIjF0mb9>`i=(NjC{P?2bcU^YB0k>cL AY5)KL literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level12.dat b/generated/amiga/levels/level12.dat new file mode 100644 index 0000000000000000000000000000000000000000..13417e5c3ecf0e44e6c72664f31c38c66a696311 GIT binary patch literal 2067 zcmd^9yH3ME5IkS9HkK(Ox}=JfKj05|IF&mP6*xd~MB>#%0wMu|FUTjedvD)y35_d8 zvc0=AJG;7^a(3Pv7EOIoH`9i(syg742^%oRc#-G%(f$~Y$748|m2f&6LCr@n=OyGU z%QznlhC={$eH-BB4iQ(crhh`)kM3jF@Ky-%hR=6O_>SZwKK<;MnriDkos0EP1#~XY zyUyk5AD&BZ_kb?iy*Y789!x^NQG2;@_!6IqWV65~jpb6*CKl`UdM>cy@QUH;Wwnx3 z{~8FYSQh^j0u+d#L}{5qhv_tSI|7|0Rf|nanVVG2WrfN+YXmxFqlf6keoEy^xpCh9 zjdjv1Z1Vl2z`mSPm&^*K+`#JGK5`_!9QAu74YZiL6Mkq!q(Mq&crD)IXZDk(vCbM{ zKoT`vFMx>$;t)os8>mYW<{j(&Au^Uyug%Pj&1fL0BMYxvx+$HN=~}cLSxHX2Qr-cP h+Y22wNMNV|Sq25?e9xeOs&XvS3RDH(1b}nL{s19r+fx7l literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level13.dat b/generated/amiga/levels/level13.dat new file mode 100644 index 0000000000000000000000000000000000000000..1b8046aad3d0a10101434486c4e40758c8015f2c GIT binary patch literal 2091 zcmc&!+m6#P5OsY?YNy&o^nrcgp;G^VFW}a4QF#LK11k-?RiD%{!seV&-Amk)z6jHFVvZSsg~M_*-T2Mv^J)y>Uuu6RzQJwh@uFvItl+z2k;H2{xdb@ zEs|f!)|8-M{};Obb^lDtXu|LKWIpz7n+eIs7g7~_+ncs6iX*z!hz!pywyxTnvKDl| zb7s6Bz^~57zJ*WFBH%p)Gvwp*zy~^Iu(aoTIA0L*l8-W}8{W;pFE&Dj$9O-S4L*j! zybeBK8ox~JhqA6wY{;b`1h^!S({WvUjr97GZYJRB3N|r@$v!obi(W&)7tZtLI_$*lx}%4>bd+3R zoOEG6EC^BKEBFch(l*}I_t7-XIX+?G!7?Abf%bbzdWXa5;B4kAd2bkH+)c6ya UN7SZi5{tA4RZVWn7%3C`7qP$>@c;k- literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level14.dat b/generated/amiga/levels/level14.dat new file mode 100644 index 0000000000000000000000000000000000000000..651e35ee03677272a11a7d35d30376816af6ef9e GIT binary patch literal 2079 zcmcIk%Wl*_5cJM?Jhp-Y3R04zrTjtufX4<7D=r{T>;@9aiNsr43D^UOugLGFpEJ|; zMu`xqgFW3{U0pqE?dvy-5No@Vo`k{Xo4*c`Shkm~Q z$e$eT`WFYSe|dE1+iEs*&ilF!p=n~Q-k*)|;Q}ztE_;J36ZO5utNY;n9fps?@CQfI z9EiV{G35VgE{LgbUFV7+dJ^GF38NM>w;-uiZToLL*!>&qzFNZJ)94a-m~pXfE_8l{ z&Rcovy?h(JvM@TYmU4HayoOrcqNgRL(%bNia}(I_z?Mt|dTTap0 z?Uh^Zh|}ml*UQQXwz? zu!R>ZJhC45!grd!BAuhun)FbD{YjqnQkRUEnC3DO{i&2@gp7OK6}2GDW+6J~T15;y TJ0Vq}%)o5^USVJe&gJSg`4Ik5 literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level15.dat b/generated/amiga/levels/level15.dat new file mode 100644 index 0000000000000000000000000000000000000000..957a05876c858209619565b42cefcf7c8bfb2765 GIT binary patch literal 2037 zcmeHH%TB{E5VR9)Q&r*s;=rxnz#ni3r$`(?oFF_3rY+Y4{3|BXQR13wxGor5>L?@}n%7 zjoV=KVCq%VgCq1vWAIO*W&>!ELC4~;5OtJKWKCk8%T&!s$BHoj=^*MLAYDRW75vKq zEN}#*zyTrdJLU~*#!#--$;3e?PSNpJyYV+>P{DriH$}IL`oRICUCLkwmx(%)B8Cy& zU;7hdh@CqnFJNSy`#=O+bfSii+s)d3N=e1Pr-x-NzpgM0yfmTEt$@+mI6C}cgfsKG Tku~(Q4gf2UG;D{KYgWGj?N$Fx literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level16.dat b/generated/amiga/levels/level16.dat new file mode 100644 index 0000000000000000000000000000000000000000..d7057b9ad29f433fb34483c1def42dde405b76e5 GIT binary patch literal 2049 zcmds&&rSj{5XRfGWo`Cm!U0btzJYI`22UJJG~OU8NhMKlBpR-q^bIgPtTSJ?-L|_1 z|8Vjp&o#}K5TwPC2%E|a}Xh=9}kP3kWwje zd!OmbY`F!MJ>wXg2kj2Yrm2k2t&to4$?^rrdlUjM1ETSUlZ??v2judcC%!cUSSvx^N*#tyUM7GL2qoxqMkJk&VZ;oC&UOsU)?MTAQyVVnb{C zP4S8iV=^w7jt-$g?8CG#S>B*}EFY$v^8mPq$4thBe7>kT9UA0-i-qIpy1ISN;sgu` zqSJ!OE(}8)@yta^LtG>pQa)*%MB>j}TAt2B!*D<3qsEA%<1!d=?BI4nJ@#=i;OcU` z16)&Ha4WvI(9?P7m23mYO>ivQKwD4@jr{}l+(B`{QsU*nmN=%wn(x1UQ(8hs!WZ=4 l$ZA59 literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level17.dat b/generated/amiga/levels/level17.dat new file mode 100644 index 0000000000000000000000000000000000000000..194b2644c410d6f9ced65ca8f9989ffc0aa20c21 GIT binary patch literal 2079 zcmeH{yH3L}6owPWj$0Km&?3>WMDPZ@0hbhpE(pX10tQowyHp8S*xNYo%sIA?onDAK zbnHLm{Ql0LW5-f^bv-?wrfKVVdzYVdGkz+1$=P0)7l*xM zaFFry{fu91Wl1ZFW5$w%b0N}H%IN-aP7hC@MS7dBq`OUS={>FGUMi5A-Z-y07rNeQ zbTpdI9}u|GQe+J|m&)^y3ojT}y#)XxEvsH_b$cjSsY*dXpsRP=1q2+MRrQPy%oy1d zY4Av$yn%J8by;df$J1sI1(hal_pd$u4mjiA6r9n|Qqy1a{$y&$P<59!2|;i_cJ<=m zr>!4uNo6!r1$nFk1tjPj?D%UuHnev3Tp{sYu^X9Gebd#>no37T`@LesE6rE1!LfZ=I%#TzE H#zOZGEei)Y literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level18.dat b/generated/amiga/levels/level18.dat new file mode 100644 index 0000000000000000000000000000000000000000..a283dc5dc469a901c474f559c778fb7a8cb68dc4 GIT binary patch literal 2037 zcmb_c%TB{U49ufx8dZuazM$X0A8;tAo&fO!kOjiE5~$byI5W2Qk!&hBFj6dc@ zmOdyM1Dr7?BpHrnF3xgOE9S}v>la@z@+HJA8$B4%ujlnEPj4gmyr^Wn8aaV)gNz26|`bt zLDaEm3M_+j9S7P5k8YZQ65eEuV0c#1IAd^uG5cn r&5rfnuVeroL-OA?4W~^sW!bc7e&f+L+ikqXwSsJ;g>-G2s*1Ni@U;GZ literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level19.dat b/generated/amiga/levels/level19.dat new file mode 100644 index 0000000000000000000000000000000000000000..c7dfc6ec3147bcc54e58b89b80e8604d28b16f6a GIT binary patch literal 2061 zcmd^9yHdk25R?@w#$y+m6oC$Z1Ao9HRO!Od!2k&&yqnBG=^se=Ek27q$tTG&$)uoT zCu85;uJ=wCC)10`Q8pQ0jwjVrh`nqmhXc_QLddM&SI4p}PqL9Z-5;fAgM)N@SmsJ3 ziIlxwnu?pd2)7SFw|Z6WgzNvN(}ZVp)p{N6=nM6C%dJA_NT&m`<`_X&hoTce*u-5O zo3!uq-}~0&gHLSZ)S4SvyDd6D6Qb=p^7_lZe#W)dFW9>KHlDP8MgCa1J=?xC0pP~8 z>1qWX1OX_kp@Y#IjO^ZP(uLq>bQM&oXmp147nJ9-NH@Dj!ALNa{s!pXSuFH9-5F!5 zczhyUek_-!>b@}q=1Iq@4kXYiDwS`72nnU||H24~Jdbpav}%{;Cg-%>7ZellBXHW* zSKfWp7&i)6E7uQw14v;&d~r`d>UQ!iXb)TWo>sI@DBZ4V_rK?$$8wjadunb zPH|szB#G@cM)r)d^nT{aKKlta!kvmnXIL|$)2T?OXfF+`=x0b!Nn+xRA@R<67PJLb L!ylhe5P9|ossme8 literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level20.dat b/generated/amiga/levels/level20.dat new file mode 100644 index 0000000000000000000000000000000000000000..ac96165e14dd040f86ddac82aaf8ed37d66a011c GIT binary patch literal 2079 zcmb`H$xg#C5QZJcjw6B);!>n?$cYEw4Om(@^hU*vimj$CyHo^RxU;-M)mLRElV!4% zsOm4p9smCPPvfRJ?f3RfuXEPvg?%A}wF(fX#_**2=% z-gJ-GTIwV;t}h%%O4n6NYtJ)ATwE1&c?&AL=V2oa7&kBOn180uz`la4wRdn?`v6yL zJVnMwVnlf}CW>Ij3?1s5x@jLMa%PU>6xK0`CFgLS7lG(4!R4c976lgaR?NYHKH3`$ z{rbjnXrIBt#yHFF181b)pQNIr!C4iTQOk00@;rps@e1{A%!T!N6dvb=a0D-wjwR_j zwDunCm>Vbd;?Id~1Wq7$eEqKwOEntVPC9|2Yr)C%4(0T1I*nZZB`rGXYEY5^mCPW3 zH%S9=uCygEX+lMV8Vk2b-)K+KND<1@#+NjrLw!?@AR6mAPGMb>SHy&hmZ+xwrVC<4to` r!f~LghD}v=(TlS254Kop*NJW{Siqc&OiN<*rN~$m8$&PmVF? zik(Qe5$-am+(pp1OQ3TNWaCG%?;acHAQ3i3PX^9&0kfI$oRROlxEiGoTqC_>m@Xwd zjHxZ@&C~O}>G>;_qlWcgqgs#j9)DDQk($_s&P2KbD*#&WCM_S5_X;ylgSKtI2!W_d|#-sP;qphW` zNRO7LhsHWxwxivUW$vnRn5d-msaxg97Wb8THj*&&te?X10m*(g3I*1(t!E^ z$~<5posEl4WDH!Po2nV{CvHd#O|3-zqEY@oDBggW1=e8`oWa)Lub5*%AGimb-u+A~1m1ITS zt6n@+x*BU;?O9~r3&y8Zo|XV}#2KlP+NtCi-6=25NR2E}FkVgXb%UXiLA zD-acpmmlsVczmA7m%N=Qi*1!}_X0n-rytHTGNWM`Hwnw}c)Z4HW?$qt@Zs0tpwW6> zu5iivnFZJ^=>2fYnmXY7o@_>}30rX}mvUz{X<1H{;crnoTOnJ}i4`zxa@vKo7Vjqv-o8WNQf8gBM}pS`o7Qrr>#CK~p4v_S|5L}phhHpwz@~Ah)Pldb@HvaL8Kc!u91&}r Gv;P2@pbmim literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/level23.dat b/generated/amiga/levels/level23.dat new file mode 100644 index 0000000000000000000000000000000000000000..e83b4c4e04c949e33f736e1a94cbdfd35e21e65b GIT binary patch literal 2049 zcmd^9!A`?43~kdT*)(yPgv4puH}D65#;G?D`~VAMz^xK!Uyq;TG)YsY3ULA-ocQ_G zcAu->-M5#v-9BvF&0|HjQtNfq&_!8enycn~cD*_?H>(TNRCTRXB>Hj!6N;PDjTWiP75Vq>0?c%VVn zaDtct&|qXiLnzckW6E)(aHfk)d2yQJM&oc_a(v4OJZyBBPk0{8$XGfIWkeSihn!CY zpYiZnS{r*Lg_FmOv6dAqilHG2`{zVo4nn*B6dq#vym9qi=3)pv!amU#HK8+zUS|&o kZ@8v!Vm%Ynb*-&ZDm0#3yiu8K0cIR*P%-(|( znWJ1}0Nw3lc5e4h(&sOWcll!eb-tK=GscYH+VgXh!LoUljowcSxtN~Hhv}JooR%^# z3%M*$WLch@s)O%9Cy2mqU+reZUbD^)4r4oYd|a7_i`J94 zonbl5bv-3|9x9U9fJ2b*^i06{fsZ%uy!re@hU57`80Y|9>VTcZzXD+PwAQFV6I*oJ zL*D2FtvPv#NO-mP#U$z--G09jKIA!$6rPrHaci)j$i22zsI9DU-usR z6kA?h&`<1q3H>VcT(Oo?(KFF%LJeVv>EUF=@6~NR$Dh_5+p5}ByXz$MT-3Z0%ZQts z`aw*IA?; N7!@`fAYyHueF0>HRnGtb literal 0 HcmV?d00001 diff --git a/generated/amiga/levels/title.dat b/generated/amiga/levels/title.dat new file mode 100644 index 0000000000000000000000000000000000000000..0773faead98e489886e7316e27684aa1e8392dec GIT binary patch literal 2029 zcmeH|O;5ux42GMPZjGo`)OJX_`NRoL0&xINjE_mcw4qV73*QHR2|tS;haD%KLk9G~ z5lc&)H;-R?nGm=2bU&#t>*+-l#`9~dczFPdcMM+W;3?(=kA~x%R{UUrWx0Z?EJs#& z*Q^GmDx(OFG+WKCv^rPHY+rUF!O+f}{J4TS1N^uM&K7fDGl%se1HrkL3_9F)`O3M8 z%v5G!FyK(eYyR@Tas(hM=X+}z{QL3{ogrN1X&)vdxE@q-niQ<#>dn`vVd8T;aF%Zk z3*AdA*gHBrSn-k@XBjMrDPfV6NwJ-5G@#@faZN(pRX!{$9d91P-3@f}0#Y=EGR}*n z+_g2n-}!twGoeZWuD; z(b^ajbm7BjrL@7r-~mwF&u>6%VW-xr>wtu$f?lV!t z2G1o~2}F)#j>a>NMjsIN9E8{Y3dTh|h9wn-{KRpmEdR%kovR){m})s{Q>Lo4{XTX> zj~jmEPmV(=ke@%A`NLlo?@Gz!*g61A?|Oa&DddD7E3X3qdRacdBw~}$ tQ5b9R{Hh;#ro|vOew5FzW8A4T*=aB`@B?k9rB?s| literal 0 HcmV?d00001 diff --git a/generated/amiga/tiles/tbank0.tbk b/generated/amiga/tiles/tbank0.tbk new file mode 100644 index 0000000000000000000000000000000000000000..a7c5a92d466cfb0fb422240816bd4bf426e0dbad GIT binary patch literal 8236 zcmbtZQD{?F7(Ow2EAEoi!N%2=)|EZ%VGq}KNFFXp&15hU=0oE&PuCR>g@)QEo3U_{ z&}}MgFejY`l;FeI>wJO!$Qp(D-8vUk{JTKcR=m&HwmhF}aV^?>Qb9 zKW*O5V+DcjxB-McR|}56{kB_hzi4B=tp)Ri6~z zb=^mO1ATp{k7LpU>)+%l#P`yduU@^Jh5@o{UN=pi>pHkZ5@u5$WL^mADK>o^o#2>m z5Qj4$aB{s+%NdAL1YG|`&)0ra{XfOk2ggZJ3SbvJY1yvN;{6s=f2`PmELym{1b)Hd z_@31J51f7xNV}85uZ_IZK-Ez{02R7=COP{;(B!i7t8qNPF`68gt$zWV0Mm|56FklM zuU7!;AaNC3UsM|ds(?^G1z$f9r}^=HCcd;cN}0<;e&G9gpUJNd?n6G=pW^11th3|u zboMa&|9~#fxdgONW1Hsoe~JzXUO(eAwEK_a{a>C60vaH+Px(uN^IK0IIl%KhygzF` zH$IBL$E_c;c`^0NxX<|C86M7Wirr*!hKU~?9w^iPz+mF8-D1L+r$Xit1FRYbUIEH)aAGSsgcl~$w5 z;IplVt=25&S1RRJtA+7$^>nLs8sj&svz1B>HI`54P0oM9IX=Y|Coh4t zw2Z*`>-<<;g#JIjdEvrek7l1hKEg#57CR;t~E259LUfeJ*RaP<@-2nY|D z9tOwSNdv2&n0V}3EtS7G%>lGP<+DjKKsd#wNnH>uXl~ z@B+%e03LvPd-v=a9W9j*|IqO>hZc9ecMjpD(rD=an!Aqh{!Kx6WJ}cA78|~Ua4%ai z55k3*sZ?e#Y6>L90QJy&SCJxwaB7d}Ee_#Zx4VAA&NmSr>b@}V#Pd5gwpH&vaQ>B0nf?1S8EhhB1mj~`-a+`bj^G2Hh?pLTuR7+Te+1vr zg7^kx@XCo#5!JNIW0|?HnI0!HKOH+Vw-fg8((^yh&*A$ky5G8Tg}L8~+Rg_P6H80T zz|zvhM8dIQeraAb-uDpkpAN(<3sz?#D1BWwH9U;?!z8<7IKTDb$<5bi5ue(9SGg|2 zY4teHDt!bGd}u{NrLj z9)G_be`^=K0HyHk-W?lABECLfAxyV-&R@7sRDb(Qf^>v*xv7Q0-R3mjU-bS)7ld&C x3`uvYAKwqwWX_9iLDp$~{ylY)`}(pb_j%K!m`~%6wG-Gref?vT|DPib{{h;T0L1_R literal 0 HcmV?d00001 diff --git a/generated/amiga/tiles/tbank1.tbk b/generated/amiga/tiles/tbank1.tbk new file mode 100644 index 0000000000000000000000000000000000000000..1b1b788e345e06935d4a14669d6a9f12a85ff9db GIT binary patch literal 32044 zcmeIwu};EJ6vpw}Ahv{oxOF4p2{0_0G{G1qN27bEPVPDt3=3~Sm|T1d7YIWaU%>>Y z;v`No5U#g{_3P;LKW)04``y$2Zg1|c&pmHlGiBLUFL*n>3|{LOCt340xT+`3`}(85 zZanz2#$k)I*I8|{|9#59Z{O@8q z{rnn#s|{SR>q_PeK4aWnRbkjw?J!ExBuP};9KJ7>eV47wN9Fm--^Zkf^RL)Ha<8c0 z&&@AJ$h=*=jH~`XyD3ZhTASy^EeFs*009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ X1Q0*~0R#|0009ILKmY**{#oD;NEmlW literal 0 HcmV?d00001 diff --git a/generated/amiga/tiles/tbank2.tbk b/generated/amiga/tiles/tbank2.tbk new file mode 100644 index 0000000000000000000000000000000000000000..58c3ff240dbbaabd53d1f2bdedb9e1188af01692 GIT binary patch literal 32044 zcmeIwu`feW6vy$~RJCHLZf_(00%9R)q7jo(*za_w?kbV0bnzd^n=JkdgNmVxe?bt# zV^R|@=yB_H{n;ho)27Ssocr$k^z7_yt(Hp9W%pZFU#nftZqzO+>oddo-CC;>=JzWH z)#tf(HJvMZ^sh}zcf@4n&{S?RDJBNfQuF0$sr-0zv0g2gRP0=D@G$!L8mk6dyPbUG zE$t?abt7{Zj~_h@)bY-htMzz6nUB9e-(T;qj#Y!%+n{%+6_*>Q+oz4?hMMns-|@by zVwW^SH-sSbqY#d~_cAvt{y^1W*)Yqp6X|DJnrG*K8RX~(y3chtBm{JhMgO)PzWGx+9gXv%rBV!>6z`0FFCS5L*xmEwXeZtDH%=TCXw zmwuL}CwZRBd|_c}snzPq3z%iu={LbU8@AIcyUuSzaf9xD?J)a%z zyqkY)WiNNcQ%fG}YjVAQSx`SP;H=z-bvNdr z)5)*9OL|hz+1=>>8%uv``9}cPe)lnLa&PYYuJmDw^PXBR@6Eiu7PHP>(x3U)uYh{q$KCQy@>uTQ_sG-pFUA~Zz<~GT zW1|AJD6xl#yU{Pnh$>uK zSnbs+v-|nX!thqTG!=toIX- zN#&My6~B2l%cp--994K}^p6tf(no!aH=maBcKqt!f9@U6yl%aq?6f=qMAR*%78milzV1%vGaq1KQw*JcFRwqD z@cgoUY`2@&&9+|~*u7h=jD_Rc#lo)d9+p^yk-fo2!4Ue|_B&e3vp|&h5#EdF*i{}9 zjlm+K@IG+90!i*KdCKd*Qhe-yc@^(J_2M8d=H2ysn%G4iaY`Qi8T+7#i5`#W94+Ua zVfC!0dUK>+X{<+CXEH~%OL~cRzK={xeN5#oXsN!5PD)zRn`oqZA=9~G1af&kc(BTj^iE}!go&qA-e&8Ozk4KJ{?F5P>)Vk|FuB**iTGP{%fTjNtnD^dPz<}qX z9`k5J7NJ#IT29k-qE`UWi@b&l(fV5EFjhQ79n7d_jPP>Sffwsz-T=zWrZZY!*Vc1L zB#f8pAU&>Uj%PbdEkIAoruwF>qmgiZGNv@El#?B!dPvM8n&F|oTCbMT6}17M`_0Fr zXsT4YmHHa+@Fi28{2J+%!lcKzJO(^&0l4TH6V*sD8gCGqxHKGK=y;G6x%eHCjSlUo zTAdU$-YG?=^Tk;YcrHdbuJ1EAH?>JUl$G||>u#HMzKQ0o^{sR6R;~5RWkvyuQ51JB JoA=IT^balW;4lCH literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level02.dat b/generated/atarist/levels/level02.dat new file mode 100644 index 0000000000000000000000000000000000000000..dd590ea5eb5564fbd06e953d4ce031ff00c944aa GIT binary patch literal 2049 zcmcJQ!BX2W5Qb%A+0iIDq!)VhGxQCBNl&@Ka3j-}S`D<^GSf~j3@^aBkIVkMl2iXoYn%Q%Ay14l4s}oZcWof?s z80q^j(kL53xM`12cO=)go1^@|JyG%BbUeOWe*+A9A46S`2dK&ZXn_wQ+-WOQ>mV>Z zhr9cPPH}BV80lxUoa6|E4*Iq1NTtPa+7BRfm}`l8K8k~5^6OFyab?*MP&#oGr$uq< zzkGoXBcrOxAA&&$1xY(WnW|mLimG?fMhniXLEB4J%vXehjHk0w;Q?tBhK4x zSoOa9{r+p9jOrhSyT7{~XXEO42*~ua_LUmaD2a@llFE}N3zL})xkMFwr+Df)*`Y3zeIg4KHzw;(31cdel5cXka+FT0%W6`NBVZ_ogGe9{X}>RzMNyojBRSq5C8vXfq&h4z8O5;> zyhFeI`A~sQd1TEog02olCxFn#T^*aW z@3Y_g*5rdvXyeq18yUMTx@IOs+jZpimwlTV*Gj!$>+ailQtB1?W9jy6`qBh|8`Gq# z6?71Ipsa=tMsF}Od#_37gP&2QSEZuXX|}nbJevl(={*Wsf}zwmK=00ct}41~ESJkO zsx#8oJ}+y#>wvfK=Nj|cc}mX1zSh}o zg*%0P^^pX&XN}AmMbY@_C;RLdu@UZ6)H==T5uHv3I!Sx6tV}Fq9LrP)vBya9&NVD( O1FHHzzDP&{ZF3mqdO{Y>rrFLlW8t~BOTxU zzVMml(fcZQIr@;lsUIEw&=s;gU2|pop%vR)J*5v7&()@n4!_vLQoWb&9X{bl9}b_C zdP5r=j*f8VV_dJ-`$#JRzbIV%TrA9Le-RX*Ug@*20YRX}rfHIt%%O-A0P$NA=XfMU z2-F-;%7U>#lSRBQ(JfL$dt}0k6NC5?c{>JlFZv%kaWK$iT!pM9KXozgeSdfmTyG527=)`Q8SLG9a&hdp?ex!?U%rFW@-y? zht2XN&01mS#VF?LNj+@wTy6R=gQ`bd^E{}i4~I`G2v9jdS%RPycka3tK)=sE3lj&} N+5(7fmVsXBv*%*Z`nmuB literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level05.dat b/generated/atarist/levels/level05.dat new file mode 100644 index 0000000000000000000000000000000000000000..0b5268db5db70334c723d27503561b3f8bb46a7f GIT binary patch literal 2085 zcmeHHOHRWu6m;x5u2msqP!+Mu8MpzT6p*?rV#OaRRAO5R)MJ*yd3Yb&ahg(f1)I(y zapsNZjbD>auE)n_T-{XTvJyhr1L=k$hm|yWZcp5iIxPxy=7#FLI8>E$YEbbDa`rx(5u1J&H8QcPJ1~F_skQwLA}weQ z|2qL`!P*sFECw~+k)-EPZTBUKfN)#r1nS7k`;ukb51U!beyw=Gg#>)}Itq|fphxlq z%f>JAlmT@dhIw53a|9us9&{{AuE{x&r9o0(+Ts1co^{b~lMd7yb<~~mHM)?G57}(T z`6X#7BF_(ZaE1ECeBKkwelDaV5$LHe`ge7$KIlg=ybjlXF4UQ7;L`SEi!?IokZtFq z2YWfJ(oDIX+8m70G7H~c`JZdfbXW^ z`;fcdH0r(8sx=6tEFe{%NIpJD?GiB?9IjqkB4eh6j4VbuY%B@E9;Ls3H4l>G+SYp zay%9A0@b2bEiX_A^>``qM5E}AOL*;^9U#wZ6%AQ{`}RD0%48?bW659(8+b@os>IR0 zW#4ll_e-l~3!+jwF(*o8)+_;H3e?4>R3IoLF{Ii3T34UF8DrPV5y1(fwaK75+Sbk!fdL*ViN literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level07.dat b/generated/atarist/levels/level07.dat new file mode 100644 index 0000000000000000000000000000000000000000..b0114fa9167ed673c8310908fcc5fe29656606cf GIT binary patch literal 2061 zcma)6$xZ@65bbHEX$k7VctB61pWq+3pcif$KY#;}xLrsze7&!huI?TXDMC*zuU@_C zAw0j#ZsKhEI-PY-&bi|=e?Dq?;dXcu?%GMXzrF|$SEpfmc@~0e8t(^#wsp%x zt*dOg$dW#pfnbmBb-%V89;WP+T*V(T@_VbRi&1=2Uij9#T7+%=kxg+ zz7>J&Z>#Xz(5Ps)4B9`L0@?*zb`&)1QcZH=q{wh2g+aYaeKUX)j=K7|rK;#4>I>gA zv4ZqgR`KFRpX&H%H~F+7&)W|qHHnZ|HTYAEdkiW zSr5?AkV4?dEVKeLM4k&gDTm1;oNzD^$P2opTfil^t)!*0R{BgY7@SaUeqL5nx)sad zi_)vMOR-ia;oTULgjaV-NhJUo@o}W5QHg`H!-o(^fLR=lN~i;lK?x^CqqFizT~TYPIx zJc^?{_>z!RWzF0p89sTg5D}?(DM=Y+IrtG;wa_#ndhd^t#V{V{D#~J%4KyiP2;S%F EFLM3%j{pDw literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level08.dat b/generated/atarist/levels/level08.dat new file mode 100644 index 0000000000000000000000000000000000000000..18842ba8879a530d4f766d9a7925bd63ca43b0ab GIT binary patch literal 2061 zcmcgsL2?r@5R^C4E}PtP+uIgT;0F*)aqta_7Z4?o02il7`78Nsx@R=fC=OJG16{7& zndw$*G_w8a^X|>G+kV;Z){g-=AC@JI2!?4K)7#gl@!h(_i}PiCKQ3__Ph$$3%`lvt z#2E4su7IOrUsyZzT>fh0WwuD)p23<%x{yVe(z@v$TFA(yFVW@vD{Cvxp!!|rd_%W? z+1Is7KbQ7?j(A4*jWy!?Q=Ma^!+Zzy)?CMmF}+T*j`B&lI8313)VjKqq2rKpJ44TS z?x7pB&**3CaiRZJ=cid?PCeHQF;G%Tddf2*{VC7#_~DaVpS&o(zRuga;BYv6$fz{j zBi#Sq@1@#i$*%!)l}$&OfTLncg9HCf1Tzr0z(Admh{yx-hEB72<_3c$_&>50MTfMI zUN?)5Zc2ygZ@Ow3sM8LxaXklifM2QWc5KfM1D!aq59<#R(^+&91?vw+0GEh}x&WP6 z@F6#A9FT;+A3`FW}{^AO-?P+c*c)laR#!}{jUdvqCpt~_sSMkgb>klRKNSjTXaM#s+ UN@r)OvY-{LCOZ<}{Hizh5wwgJ>;M1& literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level09.dat b/generated/atarist/levels/level09.dat new file mode 100644 index 0000000000000000000000000000000000000000..28f3d58a35e574cde26ec4cb6963a580d3880d7f GIT binary patch literal 2073 zcmd6o+iKfD5QgQISF$&dK#HN4#*ff9=)oxk-!=5QjoWRKp25&!2(kO*{r{O+?K-ij z3#Igzy}O$EG>4UBzdT<&Y8UfY^TqT<1)aLdgQ~{DwN2AL_Mv{_PwJRB6af2h?PW!c0B*wS>jC$Tl!1z0C zI@P=fa40Pv7?0$K-cHc#HxktU-$(q|cSpAIt2Xi1`#5x;H;3GhKfG`8Q-8l-{z<<# z$nqKApF0mFQ>T8fJ6GO-n=8fn3T@(2WYcna9k0}aG5KU)7!|s_jB{OKT(8$>v}FCj za`k<+5^GtNT-kX8n>YdO3;y0hGULd za*m`ypbK7&U5VsWjGBX`Rr1P=Y?5#fCwz(|9H9LiDj?{}`cgWZHA^)H1X&+_6Vi|} zjrQiPM#;|^k)*i$^|A7?ln0Ev*29s4G^p(^P!W63RX38uee2GdxJa&9;NI<5nH|nH zM)S%z%0ao34McHw`?VBSnow2(F0ybbl6iB95|yzPEU!LJqEYi3*Vyxn|4u9GSmKZq u8@VRnqc2yJ-Z4Z>OK(O(Lz5p2eCwPWCAI$OD0|^n(dtu6oV|B0dw&5?_4|MT literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level10.dat b/generated/atarist/levels/level10.dat new file mode 100644 index 0000000000000000000000000000000000000000..c67d49ce045734271a9ca94932d5b5942a109ffa GIT binary patch literal 2067 zcmeH{%}&EG49DBFS*a$3KmxH{=riyJd~^r28;BbSm=q>HFOz^BxOec-IC1PcO%Zzl zd}y4%e~#_6+xf-xu$b1D^|U%mlBC>Gqfw&4Q^jB~ILfy3#YR4T@3o;a9i3_|3Bv1Ajg@H4Dq- za$-Pf?H%03+hXBVx+3faIF9Pb3b07jy8O)&8GwI|)?}b8I~)g*=WXfsv<>S*hyrk) zHQI(8#vFhT!paWEde>Q#OV;3A2PNEJr${!zmno70NA9yedv;Y7A literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level11.dat b/generated/atarist/levels/level11.dat new file mode 100644 index 0000000000000000000000000000000000000000..635f9020d30f1ee71ed2d9ccb4303ce142817766 GIT binary patch literal 2061 zcmd6n$xg#C5Qg2d5J;%hMH<5<5qD~iHhfh#5seM3AV4go--tH*!hK1i;hr6DFg z#(wnwiF;o%>9YCgIab)w^impi11B4|?o%3ZlPQbIh6|hJIRO;4rCd=5mveOxU$8s) zdlhG37!}`~d!o0{1CZZKe&XDt$Q#bBsKfhMy)WoK2%WH@#)U>@U7eHvo#vRX%?L@ z7}V30D~&M6Sb|t7>|lwwoVYB9mWnYXIdfPdjxqG8C@pp>T&!1v6*;v6M{^*@1C=NF ztAqkw1~@r4#c)k&@F3y7cELy^=q+>>6XxhaDphOo;|%j;Sq_TfWDKBVEx0NNV8D14 zTqv+AD#CtFP{!JvjpTg;+5}?i+juTX`8*sL`nlig=R@gIjF0mb9>`i=(NjC{P?2bcU^YB0k>cL AY5)KL literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level12.dat b/generated/atarist/levels/level12.dat new file mode 100644 index 0000000000000000000000000000000000000000..13417e5c3ecf0e44e6c72664f31c38c66a696311 GIT binary patch literal 2067 zcmd^9yH3ME5IkS9HkK(Ox}=JfKj05|IF&mP6*xd~MB>#%0wMu|FUTjedvD)y35_d8 zvc0=AJG;7^a(3Pv7EOIoH`9i(syg742^%oRc#-G%(f$~Y$748|m2f&6LCr@n=OyGU z%QznlhC={$eH-BB4iQ(crhh`)kM3jF@Ky-%hR=6O_>SZwKK<;MnriDkos0EP1#~XY zyUyk5AD&BZ_kb?iy*Y789!x^NQG2;@_!6IqWV65~jpb6*CKl`UdM>cy@QUH;Wwnx3 z{~8FYSQh^j0u+d#L}{5qhv_tSI|7|0Rf|nanVVG2WrfN+YXmxFqlf6keoEy^xpCh9 zjdjv1Z1Vl2z`mSPm&^*K+`#JGK5`_!9QAu74YZiL6Mkq!q(Mq&crD)IXZDk(vCbM{ zKoT`vFMx>$;t)os8>mYW<{j(&Au^Uyug%Pj&1fL0BMYxvx+$HN=~}cLSxHX2Qr-cP h+Y22wNMNV|Sq25?e9xeOs&XvS3RDH(1b}nL{s19r+fx7l literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level13.dat b/generated/atarist/levels/level13.dat new file mode 100644 index 0000000000000000000000000000000000000000..1b8046aad3d0a10101434486c4e40758c8015f2c GIT binary patch literal 2091 zcmc&!+m6#P5OsY?YNy&o^nrcgp;G^VFW}a4QF#LK11k-?RiD%{!seV&-Amk)z6jHFVvZSsg~M_*-T2Mv^J)y>Uuu6RzQJwh@uFvItl+z2k;H2{xdb@ zEs|f!)|8-M{};Obb^lDtXu|LKWIpz7n+eIs7g7~_+ncs6iX*z!hz!pywyxTnvKDl| zb7s6Bz^~57zJ*WFBH%p)Gvwp*zy~^Iu(aoTIA0L*l8-W}8{W;pFE&Dj$9O-S4L*j! zybeBK8ox~JhqA6wY{;b`1h^!S({WvUjr97GZYJRB3N|r@$v!obi(W&)7tZtLI_$*lx}%4>bd+3R zoOEG6EC^BKEBFch(l*}I_t7-XIX+?G!7?Abf%bbzdWXa5;B4kAd2bkH+)c6ya UN7SZi5{tA4RZVWn7%3C`7qP$>@c;k- literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level14.dat b/generated/atarist/levels/level14.dat new file mode 100644 index 0000000000000000000000000000000000000000..651e35ee03677272a11a7d35d30376816af6ef9e GIT binary patch literal 2079 zcmcIk%Wl*_5cJM?Jhp-Y3R04zrTjtufX4<7D=r{T>;@9aiNsr43D^UOugLGFpEJ|; zMu`xqgFW3{U0pqE?dvy-5No@Vo`k{Xo4*c`Shkm~Q z$e$eT`WFYSe|dE1+iEs*&ilF!p=n~Q-k*)|;Q}ztE_;J36ZO5utNY;n9fps?@CQfI z9EiV{G35VgE{LgbUFV7+dJ^GF38NM>w;-uiZToLL*!>&qzFNZJ)94a-m~pXfE_8l{ z&Rcovy?h(JvM@TYmU4HayoOrcqNgRL(%bNia}(I_z?Mt|dTTap0 z?Uh^Zh|}ml*UQQXwz? zu!R>ZJhC45!grd!BAuhun)FbD{YjqnQkRUEnC3DO{i&2@gp7OK6}2GDW+6J~T15;y TJ0Vq}%)o5^USVJe&gJSg`4Ik5 literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level15.dat b/generated/atarist/levels/level15.dat new file mode 100644 index 0000000000000000000000000000000000000000..957a05876c858209619565b42cefcf7c8bfb2765 GIT binary patch literal 2037 zcmeHH%TB{E5VR9)Q&r*s;=rxnz#ni3r$`(?oFF_3rY+Y4{3|BXQR13wxGor5>L?@}n%7 zjoV=KVCq%VgCq1vWAIO*W&>!ELC4~;5OtJKWKCk8%T&!s$BHoj=^*MLAYDRW75vKq zEN}#*zyTrdJLU~*#!#--$;3e?PSNpJyYV+>P{DriH$}IL`oRICUCLkwmx(%)B8Cy& zU;7hdh@CqnFJNSy`#=O+bfSii+s)d3N=e1Pr-x-NzpgM0yfmTEt$@+mI6C}cgfsKG Tku~(Q4gf2UG;D{KYgWGj?N$Fx literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level16.dat b/generated/atarist/levels/level16.dat new file mode 100644 index 0000000000000000000000000000000000000000..d7057b9ad29f433fb34483c1def42dde405b76e5 GIT binary patch literal 2049 zcmds&&rSj{5XRfGWo`Cm!U0btzJYI`22UJJG~OU8NhMKlBpR-q^bIgPtTSJ?-L|_1 z|8Vjp&o#}K5TwPC2%E|a}Xh=9}kP3kWwje zd!OmbY`F!MJ>wXg2kj2Yrm2k2t&to4$?^rrdlUjM1ETSUlZ??v2judcC%!cUSSvx^N*#tyUM7GL2qoxqMkJk&VZ;oC&UOsU)?MTAQyVVnb{C zP4S8iV=^w7jt-$g?8CG#S>B*}EFY$v^8mPq$4thBe7>kT9UA0-i-qIpy1ISN;sgu` zqSJ!OE(}8)@yta^LtG>pQa)*%MB>j}TAt2B!*D<3qsEA%<1!d=?BI4nJ@#=i;OcU` z16)&Ha4WvI(9?P7m23mYO>ivQKwD4@jr{}l+(B`{QsU*nmN=%wn(x1UQ(8hs!WZ=4 l$ZA59 literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level17.dat b/generated/atarist/levels/level17.dat new file mode 100644 index 0000000000000000000000000000000000000000..194b2644c410d6f9ced65ca8f9989ffc0aa20c21 GIT binary patch literal 2079 zcmeH{yH3L}6owPWj$0Km&?3>WMDPZ@0hbhpE(pX10tQowyHp8S*xNYo%sIA?onDAK zbnHLm{Ql0LW5-f^bv-?wrfKVVdzYVdGkz+1$=P0)7l*xM zaFFry{fu91Wl1ZFW5$w%b0N}H%IN-aP7hC@MS7dBq`OUS={>FGUMi5A-Z-y07rNeQ zbTpdI9}u|GQe+J|m&)^y3ojT}y#)XxEvsH_b$cjSsY*dXpsRP=1q2+MRrQPy%oy1d zY4Av$yn%J8by;df$J1sI1(hal_pd$u4mjiA6r9n|Qqy1a{$y&$P<59!2|;i_cJ<=m zr>!4uNo6!r1$nFk1tjPj?D%UuHnev3Tp{sYu^X9Gebd#>no37T`@LesE6rE1!LfZ=I%#TzE H#zOZGEei)Y literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level18.dat b/generated/atarist/levels/level18.dat new file mode 100644 index 0000000000000000000000000000000000000000..a283dc5dc469a901c474f559c778fb7a8cb68dc4 GIT binary patch literal 2037 zcmb_c%TB{U49ufx8dZuazM$X0A8;tAo&fO!kOjiE5~$byI5W2Qk!&hBFj6dc@ zmOdyM1Dr7?BpHrnF3xgOE9S}v>la@z@+HJA8$B4%ujlnEPj4gmyr^Wn8aaV)gNz26|`bt zLDaEm3M_+j9S7P5k8YZQ65eEuV0c#1IAd^uG5cn r&5rfnuVeroL-OA?4W~^sW!bc7e&f+L+ikqXwSsJ;g>-G2s*1Ni@U;GZ literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level19.dat b/generated/atarist/levels/level19.dat new file mode 100644 index 0000000000000000000000000000000000000000..c7dfc6ec3147bcc54e58b89b80e8604d28b16f6a GIT binary patch literal 2061 zcmd^9yHdk25R?@w#$y+m6oC$Z1Ao9HRO!Od!2k&&yqnBG=^se=Ek27q$tTG&$)uoT zCu85;uJ=wCC)10`Q8pQ0jwjVrh`nqmhXc_QLddM&SI4p}PqL9Z-5;fAgM)N@SmsJ3 ziIlxwnu?pd2)7SFw|Z6WgzNvN(}ZVp)p{N6=nM6C%dJA_NT&m`<`_X&hoTce*u-5O zo3!uq-}~0&gHLSZ)S4SvyDd6D6Qb=p^7_lZe#W)dFW9>KHlDP8MgCa1J=?xC0pP~8 z>1qWX1OX_kp@Y#IjO^ZP(uLq>bQM&oXmp147nJ9-NH@Dj!ALNa{s!pXSuFH9-5F!5 zczhyUek_-!>b@}q=1Iq@4kXYiDwS`72nnU||H24~Jdbpav}%{;Cg-%>7ZellBXHW* zSKfWp7&i)6E7uQw14v;&d~r`d>UQ!iXb)TWo>sI@DBZ4V_rK?$$8wjadunb zPH|szB#G@cM)r)d^nT{aKKlta!kvmnXIL|$)2T?OXfF+`=x0b!Nn+xRA@R<67PJLb L!ylhe5P9|ossme8 literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level20.dat b/generated/atarist/levels/level20.dat new file mode 100644 index 0000000000000000000000000000000000000000..ac96165e14dd040f86ddac82aaf8ed37d66a011c GIT binary patch literal 2079 zcmb`H$xg#C5QZJcjw6B);!>n?$cYEw4Om(@^hU*vimj$CyHo^RxU;-M)mLRElV!4% zsOm4p9smCPPvfRJ?f3RfuXEPvg?%A}wF(fX#_**2=% z-gJ-GTIwV;t}h%%O4n6NYtJ)ATwE1&c?&AL=V2oa7&kBOn180uz`la4wRdn?`v6yL zJVnMwVnlf}CW>Ij3?1s5x@jLMa%PU>6xK0`CFgLS7lG(4!R4c976lgaR?NYHKH3`$ z{rbjnXrIBt#yHFF181b)pQNIr!C4iTQOk00@;rps@e1{A%!T!N6dvb=a0D-wjwR_j zwDunCm>Vbd;?Id~1Wq7$eEqKwOEntVPC9|2Yr)C%4(0T1I*nZZB`rGXYEY5^mCPW3 zH%S9=uCygEX+lMV8Vk2b-)K+KND<1@#+NjrLw!?@AR6mAPGMb>SHy&hmZ+xwrVC<4to` r!f~LghD}v=(TlS254Kop*NJW{Siqc&OiN<*rN~$m8$&PmVF? zik(Qe5$-am+(pp1OQ3TNWaCG%?;acHAQ3i3PX^9&0kfI$oRROlxEiGoTqC_>m@Xwd zjHxZ@&C~O}>G>;_qlWcgqgs#j9)DDQk($_s&P2KbD*#&WCM_S5_X;ylgSKtI2!W_d|#-sP;qphW` zNRO7LhsHWxwxivUW$vnRn5d-msaxg97Wb8THj*&&te?X10m*(g3I*1(t!E^ z$~<5posEl4WDH!Po2nV{CvHd#O|3-zqEY@oDBggW1=e8`oWa)Lub5*%AGimb-u+A~1m1ITS zt6n@+x*BU;?O9~r3&y8Zo|XV}#2KlP+NtCi-6=25NR2E}FkVgXb%UXiLA zD-acpmmlsVczmA7m%N=Qi*1!}_X0n-rytHTGNWM`Hwnw}c)Z4HW?$qt@Zs0tpwW6> zu5iivnFZJ^=>2fYnmXY7o@_>}30rX}mvUz{X<1H{;crnoTOnJ}i4`zxa@vKo7Vjqv-o8WNQf8gBM}pS`o7Qrr>#CK~p4v_S|5L}phhHpwz@~Ah)Pldb@HvaL8Kc!u91&}r Gv;P2@pbmim literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/level23.dat b/generated/atarist/levels/level23.dat new file mode 100644 index 0000000000000000000000000000000000000000..e83b4c4e04c949e33f736e1a94cbdfd35e21e65b GIT binary patch literal 2049 zcmd^9!A`?43~kdT*)(yPgv4puH}D65#;G?D`~VAMz^xK!Uyq;TG)YsY3ULA-ocQ_G zcAu->-M5#v-9BvF&0|HjQtNfq&_!8enycn~cD*_?H>(TNRCTRXB>Hj!6N;PDjTWiP75Vq>0?c%VVn zaDtct&|qXiLnzckW6E)(aHfk)d2yQJM&oc_a(v4OJZyBBPk0{8$XGfIWkeSihn!CY zpYiZnS{r*Lg_FmOv6dAqilHG2`{zVo4nn*B6dq#vym9qi=3)pv!amU#HK8+zUS|&o kZ@8v!Vm%Ynb*-&ZDm0#3yiu8K0cIR*P%-(|( znWJ1}0Nw3lc5e4h(&sOWcll!eb-tK=GscYH+VgXh!LoUljowcSxtN~Hhv}JooR%^# z3%M*$WLch@s)O%9Cy2mqU+reZUbD^)4r4oYd|a7_i`J94 zonbl5bv-3|9x9U9fJ2b*^i06{fsZ%uy!re@hU57`80Y|9>VTcZzXD+PwAQFV6I*oJ zL*D2FtvPv#NO-mP#U$z--G09jKIA!$6rPrHaci)j$i22zsI9DU-usR z6kA?h&`<1q3H>VcT(Oo?(KFF%LJeVv>EUF=@6~NR$Dh_5+p5}ByXz$MT-3Z0%ZQts z`aw*IA?; N7!@`fAYyHueF0>HRnGtb literal 0 HcmV?d00001 diff --git a/generated/atarist/levels/title.dat b/generated/atarist/levels/title.dat new file mode 100644 index 0000000000000000000000000000000000000000..0773faead98e489886e7316e27684aa1e8392dec GIT binary patch literal 2029 zcmeH|O;5ux42GMPZjGo`)OJX_`NRoL0&xINjE_mcw4qV73*QHR2|tS;haD%KLk9G~ z5lc&)H;-R?nGm=2bU&#t>*+-l#`9~dczFPdcMM+W;3?(=kA~x%R{UUrWx0Z?EJs#& z*Q^GmDx(OFG+WKCv^rPHY+rUF!O+f}{J4TS1N^uM&K7fDGl%se1HrkL3_9F)`O3M8 z%v5G!FyK(eYyR@Tas(hM=X+}z{QL3{ogrN1X&)vdxE@q-niQ<#>dn`vVd8T;aF%Zk z3*AdA*gHBrSn-k@XBjMrDPfV6NwJ-5G@#@faZN(pRX!{$9d91P-3@f}0#Y=EGR}*n z+_g2n-}!twGoeZWuD; z(b^ajbm7BjrL@7r-~mwF&u>6%VW-xr>wtu$f?lV!t z2G1o~2}F)#j>a>NMjsIN9E8{Y3dTh|h9wn-{KRpmEdR%kovR){m})s{Q>Lo4{XTX> zj~jmEPmV(=ke@%A`NLlo?@Gz!*g61A?|Oa&DddD7E3X3qdRacdBw~}$ tQ5b9R{Hh;#ro|vOew5FzW8A4T*=aB`@B?k9rB?s| literal 0 HcmV?d00001 diff --git a/generated/atarist/tiles/tbank0.tbk b/generated/atarist/tiles/tbank0.tbk new file mode 100644 index 0000000000000000000000000000000000000000..d49247ae99c9d5a87f17cb057ab8c931e5d97c21 GIT binary patch literal 8236 zcmcJTO^97*8OJAy-V_bC7L?S6nJO+^bdg#qvj}t3D1xP+i>lSt(b6CoYF6zOFPU3v z{Qw0&sJ5UCU5Hz|l2W8svxs7#f)s?Jozg`Rl%@@(6FMgTez)&`czSwnj6xsy^?Bas z>-{+IIdhvk?mBkM-aSiumgaKyz?*vbzejK%hx-~G`m3P7isKQif%)o4hanR%CfZf3 z?+T71_LcOuzV(}esAZSv}lJG$IcNj=VCu)71F!TZ!bk% zNlu4fPQZH8&Jn47hx(gm{zl{L=zE|_f9>$@7My|g2- zb1okt@Tmlg-o|!5`3Kwx%S~(BXAL@V42ETwO8`}EPFV6+n+a$59 zQSIFNl{(HdFs?u188}zgvQ}>J!CzI+#HG$FZGP$^0F*ImT+- zddJy&&PJ`IJ~jRN8eb!S1j)5Nl75b>y(n&uXO+KQIXAVj{`b#)DA8|@mX=OpnD72v zTlT46$@*(d|IixsN4(2oUV7v#-M=Uk$hdDCPi==C%-651!wE08(5`bXgN*YEq=fqo^q>iSN_Kf^yna))Bow~zY& zhxCuGJ*%DgqB#<08CNZy(Su;^CD?&&I1gJegDEH*FoqSl7WRW{Y5#Mxd#3gp;=B0$ zUE=SP_c!f0ers-pvjf_gGKCpzfopWFuGzIKuEjOEHrKcUW7q(DoP#q>Ys*7mEb+&& zFM_d6sPTte?4_p7)0cW{#TsKFasBrEPKjwJ{<{{pXL_A3@$|ibuabImt&5J`kL!1? zi{M;CadT=t!(B=Kt^*_Q(ITCis?Sg-`d$au^s~l`Xn&`=zL&uq_rD3AnK~Ggi1)gZ zxOZj<<2S+Q8h4|=2k&XT3%w7#Q)5U!ZGFc44(E5Y-<;F%Y|)r+xx21otNBp#o_|4$ z{bOg0>n(TNdG|M;u^HIY_aW=k);EL0@E|1C*M#Qc+Ks8JI1Y(toV<){Jh5nV5|6L1 zjSq?E9NfQ*A^9`x;Jz1~N6sX>b_xCsZ-96CWq1LefsKZ? zK4a#XYmK#wa#%_nNz>ndM#^=%RhOF_+*mA6Ku4&YK z4|)FCmt*&KUE?OS9BoX|awj;qe=oGpzzy(WaGle}QS=Nt_%)n>r{U0lVGr@W;PW1V zd-9$BI6Mcg(S7+%9I|oG zp@UU80PAobW{oL2GR4>e=eQC(#IHm> zi)vf51+GETV;j5f%bLWEZNU^GQ;ZpOa2O7P=jUB;UzN;Xebr8!vbV{tc#eZP(b{)4 ze2!A3_S5#AySyG4Mb)b9e8)c7~AjH5Ff&yUe1-8s@L(Kg@>U^ z$G|+FdGh0PPhq>*@kLu_%?zesFL?!KFonNC{PxORL;rr6ymRD<<7&Id$KdDSe$?W9 zHup4m$NK0u?z;U=_)x>YIp*q5ykBc>>VMzzH=FNb8-EgQeSQo0dm#2x*netq&%*KZ z@T;aB{~fIVEj$Cifv3Uze}Vms^<@>uI?PJI`{14POorqP&M~~u^nP@5eV=^4{D1p3 za4i$?n|m3QN8mMZtY7=vpgi92ynJrz_{@*3PL6qE+_Sz2z9;&gg-y_JJh|#f^^I|g zbjr!ST%Wl^@o%=6IW=w{ zYeMa>?YB-YL)MV`#9wVOdrzPr|7?rzpj~f2Ugxl9;_kQ4N1M;{KGDPP=*@5+Y{6|k zKFIhJa6jyaZTM@$v-cT)A7-!x?%8*12xIaC@SXBb`JO!qiK+hxKPkG#R`B20@P1r? zTVNl!zPi7=8Oupn2hTb2Rcyb5=i$%r7#xD^U;6-D3-5&+z?#I>e*5$NU&i)1)wOE( zr62E)-N)z(%!qYYpvJSmljLhl;u#;r=l$`{IQI;?!Wi4LFi$xR=6a6S`K{Xvk+T^8 p1n-mYmpQ4$+^zTgjJdX>5OEEohP`*db5D=NeKw-sKCbDD@E@l@0LB0S literal 0 HcmV?d00001 diff --git a/generated/atarist/tiles/tbank1.tbk b/generated/atarist/tiles/tbank1.tbk new file mode 100644 index 0000000000000000000000000000000000000000..c4a9cb1010ecd973811a52053ce9aafe73c062de GIT binary patch literal 32044 zcmeIwu}%U(6op|?5eNmbvm@~dG&Gu+V2qWe(VmLRUMs=a_y&f`#<#G+P|)}aCRmA; zT4`axb8v66*|6GK{gWJKxObS{{g#8Hz0JkKcxhoarnN@-Ze_cCTijY6Ru9X&#dh_$ zcv>1)PD;JX&z}6xsan5fqW+iisk|v8rLT0A7zHhSGbN>_)Rm^vQ9O2(b0vKpe|O!r zhVn3Hj{O7mdYQQ9{@NdjGv2kYuXL41CF&imn@U~rz55ybEPf`BD`j6w4aZ-}o%A{T|zj@7Z%i%iQN{xv3F*^M3EM<9g}Ybq`-NT~DvwkMrZ4 zxL4ObqVGBO#2N0}$i6pXLkcC8iz0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ j0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL_*a2%Vkvh? literal 0 HcmV?d00001 diff --git a/generated/atarist/tiles/tbank2.tbk b/generated/atarist/tiles/tbank2.tbk new file mode 100644 index 0000000000000000000000000000000000000000..26619da4f635a86daa87df8d17360c7aacbc6a94 GIT binary patch literal 32044 zcmeIwu}&0G97b_gTy_Zs+Sy@z0tgKxCJ+_#+>IFe$y}d zLEq>jy`drHcckxB(TF}!*}unpn|A17z2?5`KW84)Z8qlY=ek4pX`Ql{wZu5F)ws_* zXvFyUIV$G1@|<62T(6na3o7rB`}qE(%rB{VUOrdWG3P1wF?&n9b)Q(j(l*61@0XSu zbMDLgZ!&LCdFQOT&wj3}{03*I7d^69D&Jphv!8j2@;fr;D(^JO+|~*E$8tuQ^(wA?5qGDYl>MHFNfImSUe`p0iTUZq?moO{;Yy)>OVD|8FVx&6!&- z1r$&~0RQ${q^$Q_2=d7i{Fo*uJ12jAHQ6_-#&-Ez>ejbM5wiU8P$!)w3>^EbBCDF?x@^zskiL3+3m&`FEp} z!K&sMWtH=~Icn^+@{8m*{aP-kt|yDhj9%;WCh*+D)=a>;dOdlX+Rk`MbS|I!-)Ae$ zki%vXzq#HXRs5!qlNa}0Y@1))q3%Ek>yzxHC@9Twa(uEMfxG-6(pUL;K8|ng zTjL|?R}uPFUaUpDfp5*{@T~gUX-G9KK4}!|7Pa$bQI$mNXEj=Xs}=va4+Xb+EVorW z1IxUx!7TPRZNI;eG>7Kh`0mbM=g9k1P}EcY#jy2NP;I;a%UfBRN69^}`;Qtb-U2sY zL5X+yOHiAwKl<Es!_kvMNhsxQJ21sweRY;=y#)N5-XzvMe+PB`fZY|ldx^7IKQyn^C*F-i0h%Va{GSoCAw2wh zTw`(EAN-YD@5)d@)bA^CUU!H3MlZ$?fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb z2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$## zAOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;| bfB*y_009U<00Izz00bZa0SG|geaq?6f=qMAR*%78milzV1%vGaq1KQw*JcFRwqD z@cgoUY`2@&&9+|~*u7h=jD_Rc#lo)d9+p^yk-fo2!4Ue|_B&e3vp|&h5#EdF*i{}9 zjlm+K@IG+90!i*KdCKd*Qhe-yc@^(J_2M8d=H2ysn%G4iaY`Qi8T+7#i5`#W94+Ua zVfC!0dUK>+X{<+CXEH~%OL~cRzK={xeN5#oXsN!5PD)zRn`oqZA=9~G1af&kc(BTj^iE}!go&qA-e&8Ozk4KJ{?F5P>)Vk|FuB**iTGP{%fTjNtnD^dPz<}qX z9`k5J7NJ#IT29k-qE`UWi@b&l(fV5EFjhQ79n7d_jPP>Sffwsz-T=zWrZZY!*Vc1L zB#f8pAU&>Uj%PbdEkIAoruwF>qmgiZGNv@El#?B!dPvM8n&F|oTCbMT6}17M`_0Fr zXsT4YmHHa+@Fi28{2J+%!lcKzJO(^&0l4TH6V*sD8gCGqxHKGK=y;G6x%eHCjSlUo zTAdU$-YG?=^Tk;YcrHdbuJ1EAH?>JUl$G||>u#HMzKQ0o^{sR6R;~5RWkvyuQ51JB JoA=IT^balW;4lCH literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level02.dat b/generated/dos/levels/level02.dat new file mode 100644 index 0000000000000000000000000000000000000000..dd590ea5eb5564fbd06e953d4ce031ff00c944aa GIT binary patch literal 2049 zcmcJQ!BX2W5Qb%A+0iIDq!)VhGxQCBNl&@Ka3j-}S`D<^GSf~j3@^aBkIVkMl2iXoYn%Q%Ay14l4s}oZcWof?s z80q^j(kL53xM`12cO=)go1^@|JyG%BbUeOWe*+A9A46S`2dK&ZXn_wQ+-WOQ>mV>Z zhr9cPPH}BV80lxUoa6|E4*Iq1NTtPa+7BRfm}`l8K8k~5^6OFyab?*MP&#oGr$uq< zzkGoXBcrOxAA&&$1xY(WnW|mLimG?fMhniXLEB4J%vXehjHk0w;Q?tBhK4x zSoOa9{r+p9jOrhSyT7{~XXEO42*~ua_LUmaD2a@llFE}N3zL})xkMFwr+Df)*`Y3zeIg4KHzw;(31cdel5cXka+FT0%W6`NBVZ_ogGe9{X}>RzMNyojBRSq5C8vXfq&h4z8O5;> zyhFeI`A~sQd1TEog02olCxFn#T^*aW z@3Y_g*5rdvXyeq18yUMTx@IOs+jZpimwlTV*Gj!$>+ailQtB1?W9jy6`qBh|8`Gq# z6?71Ipsa=tMsF}Od#_37gP&2QSEZuXX|}nbJevl(={*Wsf}zwmK=00ct}41~ESJkO zsx#8oJ}+y#>wvfK=Nj|cc}mX1zSh}o zg*%0P^^pX&XN}AmMbY@_C;RLdu@UZ6)H==T5uHv3I!Sx6tV}Fq9LrP)vBya9&NVD( O1FHHzzDP&{ZF3mqdO{Y>rrFLlW8t~BOTxU zzVMml(fcZQIr@;lsUIEw&=s;gU2|pop%vR)J*5v7&()@n4!_vLQoWb&9X{bl9}b_C zdP5r=j*f8VV_dJ-`$#JRzbIV%TrA9Le-RX*Ug@*20YRX}rfHIt%%O-A0P$NA=XfMU z2-F-;%7U>#lSRBQ(JfL$dt}0k6NC5?c{>JlFZv%kaWK$iT!pM9KXozgeSdfmTyG527=)`Q8SLG9a&hdp?ex!?U%rFW@-y? zht2XN&01mS#VF?LNj+@wTy6R=gQ`bd^E{}i4~I`G2v9jdS%RPycka3tK)=sE3lj&} N+5(7fmVsXBv*%*Z`nmuB literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level05.dat b/generated/dos/levels/level05.dat new file mode 100644 index 0000000000000000000000000000000000000000..0b5268db5db70334c723d27503561b3f8bb46a7f GIT binary patch literal 2085 zcmeHHOHRWu6m;x5u2msqP!+Mu8MpzT6p*?rV#OaRRAO5R)MJ*yd3Yb&ahg(f1)I(y zapsNZjbD>auE)n_T-{XTvJyhr1L=k$hm|yWZcp5iIxPxy=7#FLI8>E$YEbbDa`rx(5u1J&H8QcPJ1~F_skQwLA}weQ z|2qL`!P*sFECw~+k)-EPZTBUKfN)#r1nS7k`;ukb51U!beyw=Gg#>)}Itq|fphxlq z%f>JAlmT@dhIw53a|9us9&{{AuE{x&r9o0(+Ts1co^{b~lMd7yb<~~mHM)?G57}(T z`6X#7BF_(ZaE1ECeBKkwelDaV5$LHe`ge7$KIlg=ybjlXF4UQ7;L`SEi!?IokZtFq z2YWfJ(oDIX+8m70G7H~c`JZdfbXW^ z`;fcdH0r(8sx=6tEFe{%NIpJD?GiB?9IjqkB4eh6j4VbuY%B@E9;Ls3H4l>G+SYp zay%9A0@b2bEiX_A^>``qM5E}AOL*;^9U#wZ6%AQ{`}RD0%48?bW659(8+b@os>IR0 zW#4ll_e-l~3!+jwF(*o8)+_;H3e?4>R3IoLF{Ii3T34UF8DrPV5y1(fwaK75+Sbk!fdL*ViN literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level07.dat b/generated/dos/levels/level07.dat new file mode 100644 index 0000000000000000000000000000000000000000..b0114fa9167ed673c8310908fcc5fe29656606cf GIT binary patch literal 2061 zcma)6$xZ@65bbHEX$k7VctB61pWq+3pcif$KY#;}xLrsze7&!huI?TXDMC*zuU@_C zAw0j#ZsKhEI-PY-&bi|=e?Dq?;dXcu?%GMXzrF|$SEpfmc@~0e8t(^#wsp%x zt*dOg$dW#pfnbmBb-%V89;WP+T*V(T@_VbRi&1=2Uij9#T7+%=kxg+ zz7>J&Z>#Xz(5Ps)4B9`L0@?*zb`&)1QcZH=q{wh2g+aYaeKUX)j=K7|rK;#4>I>gA zv4ZqgR`KFRpX&H%H~F+7&)W|qHHnZ|HTYAEdkiW zSr5?AkV4?dEVKeLM4k&gDTm1;oNzD^$P2opTfil^t)!*0R{BgY7@SaUeqL5nx)sad zi_)vMOR-ia;oTULgjaV-NhJUo@o}W5QHg`H!-o(^fLR=lN~i;lK?x^CqqFizT~TYPIx zJc^?{_>z!RWzF0p89sTg5D}?(DM=Y+IrtG;wa_#ndhd^t#V{V{D#~J%4KyiP2;S%F EFLM3%j{pDw literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level08.dat b/generated/dos/levels/level08.dat new file mode 100644 index 0000000000000000000000000000000000000000..18842ba8879a530d4f766d9a7925bd63ca43b0ab GIT binary patch literal 2061 zcmcgsL2?r@5R^C4E}PtP+uIgT;0F*)aqta_7Z4?o02il7`78Nsx@R=fC=OJG16{7& zndw$*G_w8a^X|>G+kV;Z){g-=AC@JI2!?4K)7#gl@!h(_i}PiCKQ3__Ph$$3%`lvt z#2E4su7IOrUsyZzT>fh0WwuD)p23<%x{yVe(z@v$TFA(yFVW@vD{Cvxp!!|rd_%W? z+1Is7KbQ7?j(A4*jWy!?Q=Ma^!+Zzy)?CMmF}+T*j`B&lI8313)VjKqq2rKpJ44TS z?x7pB&**3CaiRZJ=cid?PCeHQF;G%Tddf2*{VC7#_~DaVpS&o(zRuga;BYv6$fz{j zBi#Sq@1@#i$*%!)l}$&OfTLncg9HCf1Tzr0z(Admh{yx-hEB72<_3c$_&>50MTfMI zUN?)5Zc2ygZ@Ow3sM8LxaXklifM2QWc5KfM1D!aq59<#R(^+&91?vw+0GEh}x&WP6 z@F6#A9FT;+A3`FW}{^AO-?P+c*c)laR#!}{jUdvqCpt~_sSMkgb>klRKNSjTXaM#s+ UN@r)OvY-{LCOZ<}{Hizh5wwgJ>;M1& literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level09.dat b/generated/dos/levels/level09.dat new file mode 100644 index 0000000000000000000000000000000000000000..28f3d58a35e574cde26ec4cb6963a580d3880d7f GIT binary patch literal 2073 zcmd6o+iKfD5QgQISF$&dK#HN4#*ff9=)oxk-!=5QjoWRKp25&!2(kO*{r{O+?K-ij z3#Igzy}O$EG>4UBzdT<&Y8UfY^TqT<1)aLdgQ~{DwN2AL_Mv{_PwJRB6af2h?PW!c0B*wS>jC$Tl!1z0C zI@P=fa40Pv7?0$K-cHc#HxktU-$(q|cSpAIt2Xi1`#5x;H;3GhKfG`8Q-8l-{z<<# z$nqKApF0mFQ>T8fJ6GO-n=8fn3T@(2WYcna9k0}aG5KU)7!|s_jB{OKT(8$>v}FCj za`k<+5^GtNT-kX8n>YdO3;y0hGULd za*m`ypbK7&U5VsWjGBX`Rr1P=Y?5#fCwz(|9H9LiDj?{}`cgWZHA^)H1X&+_6Vi|} zjrQiPM#;|^k)*i$^|A7?ln0Ev*29s4G^p(^P!W63RX38uee2GdxJa&9;NI<5nH|nH zM)S%z%0ao34McHw`?VBSnow2(F0ybbl6iB95|yzPEU!LJqEYi3*Vyxn|4u9GSmKZq u8@VRnqc2yJ-Z4Z>OK(O(Lz5p2eCwPWCAI$OD0|^n(dtu6oV|B0dw&5?_4|MT literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level10.dat b/generated/dos/levels/level10.dat new file mode 100644 index 0000000000000000000000000000000000000000..c67d49ce045734271a9ca94932d5b5942a109ffa GIT binary patch literal 2067 zcmeH{%}&EG49DBFS*a$3KmxH{=riyJd~^r28;BbSm=q>HFOz^BxOec-IC1PcO%Zzl zd}y4%e~#_6+xf-xu$b1D^|U%mlBC>Gqfw&4Q^jB~ILfy3#YR4T@3o;a9i3_|3Bv1Ajg@H4Dq- za$-Pf?H%03+hXBVx+3faIF9Pb3b07jy8O)&8GwI|)?}b8I~)g*=WXfsv<>S*hyrk) zHQI(8#vFhT!paWEde>Q#OV;3A2PNEJr${!zmno70NA9yedv;Y7A literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level11.dat b/generated/dos/levels/level11.dat new file mode 100644 index 0000000000000000000000000000000000000000..635f9020d30f1ee71ed2d9ccb4303ce142817766 GIT binary patch literal 2061 zcmd6n$xg#C5Qg2d5J;%hMH<5<5qD~iHhfh#5seM3AV4go--tH*!hK1i;hr6DFg z#(wnwiF;o%>9YCgIab)w^impi11B4|?o%3ZlPQbIh6|hJIRO;4rCd=5mveOxU$8s) zdlhG37!}`~d!o0{1CZZKe&XDt$Q#bBsKfhMy)WoK2%WH@#)U>@U7eHvo#vRX%?L@ z7}V30D~&M6Sb|t7>|lwwoVYB9mWnYXIdfPdjxqG8C@pp>T&!1v6*;v6M{^*@1C=NF ztAqkw1~@r4#c)k&@F3y7cELy^=q+>>6XxhaDphOo;|%j;Sq_TfWDKBVEx0NNV8D14 zTqv+AD#CtFP{!JvjpTg;+5}?i+juTX`8*sL`nlig=R@gIjF0mb9>`i=(NjC{P?2bcU^YB0k>cL AY5)KL literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level12.dat b/generated/dos/levels/level12.dat new file mode 100644 index 0000000000000000000000000000000000000000..13417e5c3ecf0e44e6c72664f31c38c66a696311 GIT binary patch literal 2067 zcmd^9yH3ME5IkS9HkK(Ox}=JfKj05|IF&mP6*xd~MB>#%0wMu|FUTjedvD)y35_d8 zvc0=AJG;7^a(3Pv7EOIoH`9i(syg742^%oRc#-G%(f$~Y$748|m2f&6LCr@n=OyGU z%QznlhC={$eH-BB4iQ(crhh`)kM3jF@Ky-%hR=6O_>SZwKK<;MnriDkos0EP1#~XY zyUyk5AD&BZ_kb?iy*Y789!x^NQG2;@_!6IqWV65~jpb6*CKl`UdM>cy@QUH;Wwnx3 z{~8FYSQh^j0u+d#L}{5qhv_tSI|7|0Rf|nanVVG2WrfN+YXmxFqlf6keoEy^xpCh9 zjdjv1Z1Vl2z`mSPm&^*K+`#JGK5`_!9QAu74YZiL6Mkq!q(Mq&crD)IXZDk(vCbM{ zKoT`vFMx>$;t)os8>mYW<{j(&Au^Uyug%Pj&1fL0BMYxvx+$HN=~}cLSxHX2Qr-cP h+Y22wNMNV|Sq25?e9xeOs&XvS3RDH(1b}nL{s19r+fx7l literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level13.dat b/generated/dos/levels/level13.dat new file mode 100644 index 0000000000000000000000000000000000000000..1b8046aad3d0a10101434486c4e40758c8015f2c GIT binary patch literal 2091 zcmc&!+m6#P5OsY?YNy&o^nrcgp;G^VFW}a4QF#LK11k-?RiD%{!seV&-Amk)z6jHFVvZSsg~M_*-T2Mv^J)y>Uuu6RzQJwh@uFvItl+z2k;H2{xdb@ zEs|f!)|8-M{};Obb^lDtXu|LKWIpz7n+eIs7g7~_+ncs6iX*z!hz!pywyxTnvKDl| zb7s6Bz^~57zJ*WFBH%p)Gvwp*zy~^Iu(aoTIA0L*l8-W}8{W;pFE&Dj$9O-S4L*j! zybeBK8ox~JhqA6wY{;b`1h^!S({WvUjr97GZYJRB3N|r@$v!obi(W&)7tZtLI_$*lx}%4>bd+3R zoOEG6EC^BKEBFch(l*}I_t7-XIX+?G!7?Abf%bbzdWXa5;B4kAd2bkH+)c6ya UN7SZi5{tA4RZVWn7%3C`7qP$>@c;k- literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level14.dat b/generated/dos/levels/level14.dat new file mode 100644 index 0000000000000000000000000000000000000000..651e35ee03677272a11a7d35d30376816af6ef9e GIT binary patch literal 2079 zcmcIk%Wl*_5cJM?Jhp-Y3R04zrTjtufX4<7D=r{T>;@9aiNsr43D^UOugLGFpEJ|; zMu`xqgFW3{U0pqE?dvy-5No@Vo`k{Xo4*c`Shkm~Q z$e$eT`WFYSe|dE1+iEs*&ilF!p=n~Q-k*)|;Q}ztE_;J36ZO5utNY;n9fps?@CQfI z9EiV{G35VgE{LgbUFV7+dJ^GF38NM>w;-uiZToLL*!>&qzFNZJ)94a-m~pXfE_8l{ z&Rcovy?h(JvM@TYmU4HayoOrcqNgRL(%bNia}(I_z?Mt|dTTap0 z?Uh^Zh|}ml*UQQXwz? zu!R>ZJhC45!grd!BAuhun)FbD{YjqnQkRUEnC3DO{i&2@gp7OK6}2GDW+6J~T15;y TJ0Vq}%)o5^USVJe&gJSg`4Ik5 literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level15.dat b/generated/dos/levels/level15.dat new file mode 100644 index 0000000000000000000000000000000000000000..957a05876c858209619565b42cefcf7c8bfb2765 GIT binary patch literal 2037 zcmeHH%TB{E5VR9)Q&r*s;=rxnz#ni3r$`(?oFF_3rY+Y4{3|BXQR13wxGor5>L?@}n%7 zjoV=KVCq%VgCq1vWAIO*W&>!ELC4~;5OtJKWKCk8%T&!s$BHoj=^*MLAYDRW75vKq zEN}#*zyTrdJLU~*#!#--$;3e?PSNpJyYV+>P{DriH$}IL`oRICUCLkwmx(%)B8Cy& zU;7hdh@CqnFJNSy`#=O+bfSii+s)d3N=e1Pr-x-NzpgM0yfmTEt$@+mI6C}cgfsKG Tku~(Q4gf2UG;D{KYgWGj?N$Fx literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level16.dat b/generated/dos/levels/level16.dat new file mode 100644 index 0000000000000000000000000000000000000000..d7057b9ad29f433fb34483c1def42dde405b76e5 GIT binary patch literal 2049 zcmds&&rSj{5XRfGWo`Cm!U0btzJYI`22UJJG~OU8NhMKlBpR-q^bIgPtTSJ?-L|_1 z|8Vjp&o#}K5TwPC2%E|a}Xh=9}kP3kWwje zd!OmbY`F!MJ>wXg2kj2Yrm2k2t&to4$?^rrdlUjM1ETSUlZ??v2judcC%!cUSSvx^N*#tyUM7GL2qoxqMkJk&VZ;oC&UOsU)?MTAQyVVnb{C zP4S8iV=^w7jt-$g?8CG#S>B*}EFY$v^8mPq$4thBe7>kT9UA0-i-qIpy1ISN;sgu` zqSJ!OE(}8)@yta^LtG>pQa)*%MB>j}TAt2B!*D<3qsEA%<1!d=?BI4nJ@#=i;OcU` z16)&Ha4WvI(9?P7m23mYO>ivQKwD4@jr{}l+(B`{QsU*nmN=%wn(x1UQ(8hs!WZ=4 l$ZA59 literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level17.dat b/generated/dos/levels/level17.dat new file mode 100644 index 0000000000000000000000000000000000000000..194b2644c410d6f9ced65ca8f9989ffc0aa20c21 GIT binary patch literal 2079 zcmeH{yH3L}6owPWj$0Km&?3>WMDPZ@0hbhpE(pX10tQowyHp8S*xNYo%sIA?onDAK zbnHLm{Ql0LW5-f^bv-?wrfKVVdzYVdGkz+1$=P0)7l*xM zaFFry{fu91Wl1ZFW5$w%b0N}H%IN-aP7hC@MS7dBq`OUS={>FGUMi5A-Z-y07rNeQ zbTpdI9}u|GQe+J|m&)^y3ojT}y#)XxEvsH_b$cjSsY*dXpsRP=1q2+MRrQPy%oy1d zY4Av$yn%J8by;df$J1sI1(hal_pd$u4mjiA6r9n|Qqy1a{$y&$P<59!2|;i_cJ<=m zr>!4uNo6!r1$nFk1tjPj?D%UuHnev3Tp{sYu^X9Gebd#>no37T`@LesE6rE1!LfZ=I%#TzE H#zOZGEei)Y literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level18.dat b/generated/dos/levels/level18.dat new file mode 100644 index 0000000000000000000000000000000000000000..a283dc5dc469a901c474f559c778fb7a8cb68dc4 GIT binary patch literal 2037 zcmb_c%TB{U49ufx8dZuazM$X0A8;tAo&fO!kOjiE5~$byI5W2Qk!&hBFj6dc@ zmOdyM1Dr7?BpHrnF3xgOE9S}v>la@z@+HJA8$B4%ujlnEPj4gmyr^Wn8aaV)gNz26|`bt zLDaEm3M_+j9S7P5k8YZQ65eEuV0c#1IAd^uG5cn r&5rfnuVeroL-OA?4W~^sW!bc7e&f+L+ikqXwSsJ;g>-G2s*1Ni@U;GZ literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level19.dat b/generated/dos/levels/level19.dat new file mode 100644 index 0000000000000000000000000000000000000000..c7dfc6ec3147bcc54e58b89b80e8604d28b16f6a GIT binary patch literal 2061 zcmd^9yHdk25R?@w#$y+m6oC$Z1Ao9HRO!Od!2k&&yqnBG=^se=Ek27q$tTG&$)uoT zCu85;uJ=wCC)10`Q8pQ0jwjVrh`nqmhXc_QLddM&SI4p}PqL9Z-5;fAgM)N@SmsJ3 ziIlxwnu?pd2)7SFw|Z6WgzNvN(}ZVp)p{N6=nM6C%dJA_NT&m`<`_X&hoTce*u-5O zo3!uq-}~0&gHLSZ)S4SvyDd6D6Qb=p^7_lZe#W)dFW9>KHlDP8MgCa1J=?xC0pP~8 z>1qWX1OX_kp@Y#IjO^ZP(uLq>bQM&oXmp147nJ9-NH@Dj!ALNa{s!pXSuFH9-5F!5 zczhyUek_-!>b@}q=1Iq@4kXYiDwS`72nnU||H24~Jdbpav}%{;Cg-%>7ZellBXHW* zSKfWp7&i)6E7uQw14v;&d~r`d>UQ!iXb)TWo>sI@DBZ4V_rK?$$8wjadunb zPH|szB#G@cM)r)d^nT{aKKlta!kvmnXIL|$)2T?OXfF+`=x0b!Nn+xRA@R<67PJLb L!ylhe5P9|ossme8 literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level20.dat b/generated/dos/levels/level20.dat new file mode 100644 index 0000000000000000000000000000000000000000..ac96165e14dd040f86ddac82aaf8ed37d66a011c GIT binary patch literal 2079 zcmb`H$xg#C5QZJcjw6B);!>n?$cYEw4Om(@^hU*vimj$CyHo^RxU;-M)mLRElV!4% zsOm4p9smCPPvfRJ?f3RfuXEPvg?%A}wF(fX#_**2=% z-gJ-GTIwV;t}h%%O4n6NYtJ)ATwE1&c?&AL=V2oa7&kBOn180uz`la4wRdn?`v6yL zJVnMwVnlf}CW>Ij3?1s5x@jLMa%PU>6xK0`CFgLS7lG(4!R4c976lgaR?NYHKH3`$ z{rbjnXrIBt#yHFF181b)pQNIr!C4iTQOk00@;rps@e1{A%!T!N6dvb=a0D-wjwR_j zwDunCm>Vbd;?Id~1Wq7$eEqKwOEntVPC9|2Yr)C%4(0T1I*nZZB`rGXYEY5^mCPW3 zH%S9=uCygEX+lMV8Vk2b-)K+KND<1@#+NjrLw!?@AR6mAPGMb>SHy&hmZ+xwrVC<4to` r!f~LghD}v=(TlS254Kop*NJW{Siqc&OiN<*rN~$m8$&PmVF? zik(Qe5$-am+(pp1OQ3TNWaCG%?;acHAQ3i3PX^9&0kfI$oRROlxEiGoTqC_>m@Xwd zjHxZ@&C~O}>G>;_qlWcgqgs#j9)DDQk($_s&P2KbD*#&WCM_S5_X;ylgSKtI2!W_d|#-sP;qphW` zNRO7LhsHWxwxivUW$vnRn5d-msaxg97Wb8THj*&&te?X10m*(g3I*1(t!E^ z$~<5posEl4WDH!Po2nV{CvHd#O|3-zqEY@oDBggW1=e8`oWa)Lub5*%AGimb-u+A~1m1ITS zt6n@+x*BU;?O9~r3&y8Zo|XV}#2KlP+NtCi-6=25NR2E}FkVgXb%UXiLA zD-acpmmlsVczmA7m%N=Qi*1!}_X0n-rytHTGNWM`Hwnw}c)Z4HW?$qt@Zs0tpwW6> zu5iivnFZJ^=>2fYnmXY7o@_>}30rX}mvUz{X<1H{;crnoTOnJ}i4`zxa@vKo7Vjqv-o8WNQf8gBM}pS`o7Qrr>#CK~p4v_S|5L}phhHpwz@~Ah)Pldb@HvaL8Kc!u91&}r Gv;P2@pbmim literal 0 HcmV?d00001 diff --git a/generated/dos/levels/level23.dat b/generated/dos/levels/level23.dat new file mode 100644 index 0000000000000000000000000000000000000000..e83b4c4e04c949e33f736e1a94cbdfd35e21e65b GIT binary patch literal 2049 zcmd^9!A`?43~kdT*)(yPgv4puH}D65#;G?D`~VAMz^xK!Uyq;TG)YsY3ULA-ocQ_G zcAu->-M5#v-9BvF&0|HjQtNfq&_!8enycn~cD*_?H>(TNRCTRXB>Hj!6N;PDjTWiP75Vq>0?c%VVn zaDtct&|qXiLnzckW6E)(aHfk)d2yQJM&oc_a(v4OJZyBBPk0{8$XGfIWkeSihn!CY zpYiZnS{r*Lg_FmOv6dAqilHG2`{zVo4nn*B6dq#vym9qi=3)pv!amU#HK8+zUS|&o kZ@8v!Vm%Ynb*-&ZDm0#3yiu8K0cIR*P%-(|( znWJ1}0Nw3lc5e4h(&sOWcll!eb-tK=GscYH+VgXh!LoUljowcSxtN~Hhv}JooR%^# z3%M*$WLch@s)O%9Cy2mqU+reZUbD^)4r4oYd|a7_i`J94 zonbl5bv-3|9x9U9fJ2b*^i06{fsZ%uy!re@hU57`80Y|9>VTcZzXD+PwAQFV6I*oJ zL*D2FtvPv#NO-mP#U$z--G09jKIA!$6rPrHaci)j$i22zsI9DU-usR z6kA?h&`<1q3H>VcT(Oo?(KFF%LJeVv>EUF=@6~NR$Dh_5+p5}ByXz$MT-3Z0%ZQts z`aw*IA?; N7!@`fAYyHueF0>HRnGtb literal 0 HcmV?d00001 diff --git a/generated/dos/levels/title.dat b/generated/dos/levels/title.dat new file mode 100644 index 0000000000000000000000000000000000000000..0773faead98e489886e7316e27684aa1e8392dec GIT binary patch literal 2029 zcmeH|O;5ux42GMPZjGo`)OJX_`NRoL0&xINjE_mcw4qV73*QHR2|tS;haD%KLk9G~ z5lc&)H;-R?nGm=2bU&#t>*+-l#`9~dczFPdcMM+W;3?(=kA~x%R{UUrWx0Z?EJs#& z*Q^GmDx(OFG+WKCv^rPHY+rUF!O+f}{J4TS1N^uM&K7fDGl%se1HrkL3_9F)`O3M8 z%v5G!FyK(eYyR@Tas(hM=X+}z{QL3{ogrN1X&)vdxE@q-niQ<#>dn`vVd8T;aF%Zk z3*AdA*gHBrSn-k@XBjMrDPfV6NwJ-5G@#@faZN(pRX!{$9d91P-3@f}0#Y=EGR}*n z+_g2n-}!twGoeZWuD; z(b^ajbm7BjrL@7r-~mwF&u>6%VW-xr>wtu$f?lV!t z2G1o~2}F)#j>a>NMjsIN9E8{Y3dTh|h9wn-{KRpmEdR%kovR){m})s{Q>Lo4{XTX> zj~jmEPmV(=ke@%A`NLlo?@Gz!*g61A?|Oa&DddD7E3X3qdRacdBw~}$ tQ5b9R{Hh;#ro|vOew5FzW8A4T*=aB`@B?k9rB?s| literal 0 HcmV?d00001 diff --git a/generated/dos/tiles/tbank0.tbk b/generated/dos/tiles/tbank0.tbk new file mode 100644 index 0000000000000000000000000000000000000000..63caf7c9cb3285e23a692270bf652c0e92687b00 GIT binary patch literal 8236 zcmbuDOST&~3`C`qX$I0#vIP$_&a$(p)U5(Yu+_4Y6Ur1n015>VZ1wY>fBpF5x98*e zaQplBj9{?WXae>ppn zvYX?txHgA-xoZo0qmeJ?P5FZht08uNmtE!JDsK+9SNb{2ZsyOC0Ly?6ny`FQoYv7< z&@?`|Z??%&_Yay8Gv4%;qhz1(kx%BcTOJ6>T|b7arvu`jwu!~OTg_Ka#Cbh(T1TVP zQ1ss~R9^EEa1pFzIL9PJ#_4O|&8foSEP2ogSqBOlU32!F`#AUsUiq3S4w<-Of2~W2 zHPdS30AGs_>)BYUALldW*LmqrAE?;=s(ZI?`z;e&HqXIg^5=V{`>hoAC%KbGV^PjM z*SNTp{lYI7xAG@Ri-(18Q0lgxFP!Oeq?roHZrQGV5SIPoZ{0%Q^4NngU=64(+Om($ zNBpPUNjUrmCDiWbcYE!hka+kHdOma3{!wV-}`MfBYW5 zVZmP&FYh<;xz1u-BpiToi=Z~h{q8)3`uGx4;^9nYyx8ACAMvTubuW+KeaZRt!eMgH zy@k8^j~G!{`QO=P=D4t*a?dv>%RS47;u|IX*kU*`jxk&oD4d^WencOBX=iQ|k3!}}r7hnyRACYeLL?MwNK zyzIW2|7ySC*Z9q#6n-IgElTI#HFD%~HMrk(NS89>YXewL&w2C@s|z&;i58B#(o-T^PU%{x z!Lx*J1-oU}ZljmIhVR0^IB9rc1ye4H06(~^ExFQPyJ#F(xq`Dfnra4LhYs}oK z8s?5o{3KR(An+QYFJC{L*9C0loMhyC|5!&Q*Kyfu2n}4C<=T=ycq=}EYs8#N6Cy3qKd#|tKkV|xFqNftdqo79gDKTHARr%X) z;+1YLo#=LJ?OtEUA-5mN*BHke64Htj)5r2+%hjjj>ZtlkfiUBLh>I2P`z-lC=rh|l#g7a}$QYaqaCa@%`6Kvyee3Ge>@a;wRNCljrIv^B+rPFB^Ecs$yZ zA5JLv#3`fsVV?$BtXD*;8}!&;^20K^ZLiV49j0c?cv1ZrUt}2}P5_=$gJR2^UDu?) z0bzw)hlE&PREkD6Sb1VRR*L^@I-8vD+~&ZXt51EYQGjMER7NU9+t$t1@~uLqdjtim zF-1@L<2@no3fh8}vPViKtK3OZ5-j(5VBd1arGp+FIb>pZ4B0n8_HB+wJJ@?tacmAO z^SL&3`@0*J9X>Sx@UbRDHWLFSwbvm;h9%bBj7)f>hiuTo)&~tYbZ_OZ0?CN!wC^N2 z0^_zERgOVjW}HkoT;x(Szw*ENE&JHwS~{EGl5Zc=Kk;5aXtGD{^{x5r%1hRglN%A8 zozha-W9iZ$9j%hHVMiaCI>iJ{N#1l4q6Pw~1u*xFbdDy%qU;oToFZwi0OVFHKHMD+ z8MjO9TOltLI(KxMhAgQiWZxThue#jvOVe2KF=>#?`PMmx5d@z$L4-@vv}G(E?$<~_4D#|U1N?vhre5|kZu@y z-Cd`yQ`f1Rx1twK7e@^0ykJo0&^F~fQ`5I3^n_OCvENU@%FvhZS2pt6qDp?bryQiyncYm9Pg5StX{qlKWJxz{e>1n1XrM<=PdNxf&*}s+ydTvW1A!jq(=HTvUly- znce;3`^US>S=-LKw6BY+<;VHW@_l}N_PKso-p-%bPxIHscJ;D&Th*N3@0%0tXy&Ht z+NN*2sYmDOrKbLQod|EdNasrfRm#dkPE()O>z;Kqs9Akdl-7HX3g&j$JC>%jzU}|A zn1WtlzqTjWyDVNeO=(F{WEdyS`|uvL`=ec!uUiUqIZMm2^I9+Zu61qb^62OBy$dVo z3I0#2&)BWNj{71I2zn?aWsOv3b$I7RtBQuTEtjjLrt z*_1OiZ@OGdm3?_ZT?daYI2NfiKaC|cR2B^Z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 Z009ILKmY**5I_I{1Q0*~0R;Y;z&GRNEaU(H literal 0 HcmV?d00001 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)); + } + } +}