Optimized a bit better. Examples are a mess of broken crap.

This commit is contained in:
Scott Duensing 2026-05-27 20:22:24 -05:00
commit 6804baa6c1
112 changed files with 6451 additions and 0 deletions

2134
MECHANICS.md Normal file

File diff suppressed because it is too large Load diff

141
PLAN.md Normal file
View file

@ -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
<dump> <out> <start> <end>`, 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.

96
VERIFIED.md Normal file
View file

@ -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.

BIN
assets/font.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

269
assets/genPlaceholderArt.py Normal file
View file

@ -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()

92
assets/levels/format.md Normal file
View file

@ -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<N>.png`.
2. The Makefile bakes per-target via `tools/assetbake/assetbake.py
--type tile --target <port> tbank<N>.png tbank<N>.tbk`. Output
lands in `examples/spacetaxi/generated/<port>/tiles/` and is
staged into the runtime tree at `build/<port>/.../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.

142
assets/levels/title.txt Normal file
View file

@ -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

BIN
assets/sprites/sprites.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

BIN
assets/tiles/tbank0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
assets/tiles/tbank1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/tiles/tbank2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

496
extractFromDump.py Normal file
View file

@ -0,0 +1,496 @@
#!/usr/bin/env python3
# extractFromDump.py -- convert a VICE C64 memory dump into JoeyLib
# Space Taxi assets.
#
# Reads: /tmp/spacetaxi/mem0000-<labelN>.bin (VICE `save` output;
# 2-byte LE start-addr header + 64 KB RAM).
# Writes: examples/spacetaxi/assets/tiles/tilebank<N>.png
# examples/spacetaxi/assets/levels/level<NN>.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: <dump-path> <level-name-without-extension> <tilebank-id>
#
# 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 <char> <screencode> -- 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 <dump-path> <level-name> <tilebank-id>", 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()

BIN
generated/amiga/font.tbk Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
generated/atarist/font.tbk Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
generated/dos/font.tbk Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more