diff --git a/MECHANICS.md b/MECHANICS.md index 4310a98..24165ea 100644 --- a/MECHANICS.md +++ b/MECHANICS.md @@ -572,9 +572,20 @@ in $4FCB, $6BE7). The actual title-screen DRAWING happens via 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**. +`$4955` is the reverse: gate-off voice 1+2, noise off, then load a +disk file selected by `$6F75,X` (X = `$716B`) via the `$9936` +fast-loader, set $5E9D=0, JMP $5EC4. **Exit-to-title cleanup**. + +CORRECTED 2026-07-20 (demo-subsystem trace): the `$6F75,X` table and +the `$9936` loader do NOT load music. `$6F75` holds a FILENAME DIGIT +patched into the reversed "S0:SPACETAXI 0_/N0S" string, and the four +"SPACETAXI 01..04" disk files are the **attract-demo recorded input +streams** (RLE `[joystick mask][tick count]` pairs; H/T/W/X screen +rotation, `$6F75` maps H=01, T=02, W=03, X=04; playback substitutes +the recorded mask for `$DC00` input via the `$48F2` hook). `$716B` +is the demo rotation index, not a music index. The music engine +proper ($CB00-$CFFF, per-voice streams, freq tables) is unrelated to +these files. ## Lives + death state @@ -955,7 +966,7 @@ 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) +JMP $CD56 ; effect command (traced -- see "Effect commands" below) normal_note: CLC; ADC $CB86,X ; transpose @@ -965,8 +976,8 @@ 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 $CBC1,X -> $D403,X ; pulse-width HI ($D403 is the PW hi register) +LDA $CBC2,X -> $D402,X ; pulse-width LO LDA $CBC4,X ; filter flag PHP LDA $D417; EOR #$FF; ORA $CBAB,X; EOR #$FF @@ -985,13 +996,85 @@ 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. +Frequency tables (CORRECTED 2026-07-20 against the trace above: +the earlier version of this line had lo/hi swapped): `$CEF1-CF51` +holds the 97 freq LO bytes (written to `$D400,X`), `$CE90-CEF0` +holds the 97 freq HI bytes (written to `$D401,X`). Note codes are +1..97 indexing both tables. Verified against raw.bin bytes: +`$CE90: 00 01 01 01 ...` (small = hi bytes of low notes), +`$CEF1: 00 0C 1C 2D 3F 51 ...` (lo bytes). -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. +CORRECTED 2026-07-20 (full byte-level trace of `$CD0E-$CD55`): the +stream is **one byte per note event**, NOT (note, duration) pairs. +After the note plays, `$CD0E` re-reads the SAME byte at `($FC)` -- +the pointer has not advanced yet -- and `BMI $CD24` tests the NOTE +byte's own bit 7 (the bit masked off by `AND #$7F` before the table +lookup): + +- bit 7 CLEAR: default duration. `$CB84,X = $CB9B,X + $A2 + 1` + (next event = now + default duration + 1 jiffy), `$CB83,X = 0`. +- bit 7 SET: staccato/gap mode using `$CBC5,X`: if `$CBC5,X` is + negative it is first added to `$CB9B,X` (clamped at 0). The result + is stored BOTH into `$CB84,X` (+`$A2`+1, the note-on time) AND via + self-modifying code into the `SBC` immediate at `$CD3F`, and + `$CB83,X = $CB9B,X - 2 - (that value)`... i.e. `$CB83,X` receives + the GAP length. `$CB83,X` is therefore a gap counter, not a plain + flag: when the tick at `$CC74` finds it nonzero, it gates the voice + OFF and schedules the next (note-start) event at `now + $CB83,X` + (`CLC ADC $A2`). "0 = on" in the state table above really means + "no pending gap". + +Then `$CD43` advances the track pointer by ONE byte (the note byte) +and refreshes zp `$FC/$FD`. Flat stream, terminated by `$00` (end of +track: gate off, pointer stays) or `$80` (end of song: pointer set to +`$FFFF`, gate off). + +### Effect commands (`$CD56`) -- traced 2026-07-20, previously "out of scope" + +A stream byte whose masked value `(byte & $7F) >= $61` is an effect. +`$CD56` re-reads the raw byte and transforms it with an +ASL/PHP/ASL/ASL/PLP/ROR/LSR/LSR dance that computes +`cmd = (bit7 << 5) | (byte & $1F)` -- so `$61-$7F` map to cmd +`$01-$1F` and `$E1-$FF` map to cmd `$21-$3F`. Dispatch (compare +chain, byte-exact; interpretive names are inference from the code +shapes, marked *): + +| cmd range | bytes | action | +| --------- | ----- | ------ | +| `> $30` | 3 | skip two operand bytes (unused command class in this build) | +| `= $30` | 1+14 | *instrument load: copy the next 14 stream bytes into `$CBBF+`(voice window) -- ADSR, pulse width, waveform mask, filter state (`$CD71`, self-modified X operands) | +| `$20-$2F` | 1 | *set repeat counter: `$CBAA+Y = cmd & $0F` where `Y = $CB85,X + $CBD6` (`$CE84` slot indexer) | +| `$18` | 2 | set per-voice transpose `$CB86,X` from the next byte (`$CDB2`) | +| `$10-$17` (not `$18`) | 2 | set per-voice default duration `$CB9B,X` from the next byte (`$CDC3`) | +| `$0C-$0F` | 1 | decrement repeat counter (`SBC 1, AND $0F`) (`$CDD4`) | +| `$08-$0B` | 1 | *loop-back: condition via `$CE68` (cmd&3: 2 = if counter zero, 3 = if counter nonzero, else always); taken -> pop `$CB95/96,Y` saved pointer (`DEC $CB85,X` twice) and continue there; a negative slot index instead resets `$CB85,X` and ends the song (`$CDE8`) | +| `$04-$07` | 1 or 3 | *loop-start: condition false -> skip 2 operand bytes; true -> push `ptr+3` onto the `$CB95/96` pointer stack (`INC $CB85,X` twice) then fall into the jump below (`$CE17`) | +| `$01-$03` | 3 | *relative jump: add the 16-bit LE operand (bytes at ptr+1/+2) to the track pointer (`$CE4A`) | + +After most commands control returns to `$CC99` to process the next +stream byte in the same tick (so command chains execute +"immediately", consuming no time). + +### Song loading -- `$4955` is a patch stub, the real loader is `$9936` + +`$4955` only silences the SID (`$D404 = 0`, `$D40B = 0`, +`$D412 = $80`), then: `LDX $716B` (music index), `LDA $6F75,X`, +`STA $99A7`, `JSR $9936`. **`$6F75` is not a pointer table -- it +holds a FILENAME CHARACTER** (observed bytes: index 7 -> `$31` '1', +19 -> `$32` '2', 22 -> `$33` '3', 23 -> `$34` '4'), and `$99A7` is a +character slot inside a REVERSED filename string at `$99A3+` +("S0N/_0 IXATECAPS:0S" = "S0:SPACETAXI 0_/N0S" reversed; a second +string at `$9990` is "SPACETAXI HI"). `$9936` is a self-modifying +IEC fast-loader (raw `$FFA8` CIOUT / `$FFAE` UNLSN serial traffic) +that pulls the song file off the disk. **The music DATA is not in +the resident program at all: each song ships as its own disk file** +-- the `SPACETAXI 01..04` / `SPACETAXI HI` PRGs (present in +`stuff/spacetaxi/extract/`). Whatever song was resident when the +raw.bin dump was taken is what sits in its load area there. + +Timing base: `$A2` in the tick compare is the KERNAL jiffy clock low +byte, so all durations are in PAL jiffies (50 Hz) -- `$CB84,X` is a +"next event at this jiffy" target, not a countdown. ### Two reset variants diff --git a/assets/extractFlame.py b/assets/extractFlame.py new file mode 100644 index 0000000..8fc7d21 --- /dev/null +++ b/assets/extractFlame.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# extractFlame.py -- wire the engine-flame sprite cels from the C64 ROM +# image (stuff/spacetaxi/raw.bin) into the sprite sheet's flame row. +# +# The flame is C64 sprite 2, MULTICOLOR (verified: $D01C bit2 set). Its +# per-direction cel is chosen from the pointer table at $6DB0 (indexed by +# the direction mask $716A). Reading raw.bin at $6DB0 (2-byte load-addr +# header -> +2) gives, and the port's kFlameCelByDirMask mirrors exactly: +# mask 1 UP -> block $D8 mask 8 RIGHT -> block $D7 +# mask 2 DOWN -> block $D4 mask 9 UP+RIGHT -> block $D3 +# mask 4 LEFT -> block $D5 mask 10 DOWN+RIGHT-> block $D6 +# mask 5 UP+LEFT -> block $D2 +# mask 6 DOWN+LEFT -> block $D1 +# flameCels[0..7] load from .spr cels 18..25 (sheet row 2, cols 0..7), so +# the cel order below is the kFlameCelByDirMask index order. +# +# Sprite data lives at block*64 in VIC bank 0 ($3000-$37FF). Each C64 +# multicolor sprite is 12 MC-pixels x 21 rows (3 bytes/row, 2 bits/pixel); +# each MC pixel is 2 hi-res pixels, so it maps 1 MC pixel -> 2 of the +# port's 24-wide 4bpp cel pixels. Colors are the live-level flame palette +# ($7D05->$D025=$02 red = MC0, $7D06->$D026=$07 yellow = MC1; sprite-2 +# colour = orange), mapped to sprites.png's indexed palette. +# +# Patches ONLY the flame row (row 2) of sprites.png -- taxi (row 0), +# passenger (row 1), and font.png are left untouched. Re-run the .spr +# bake afterwards (make/.mk STAXI_SPR rule). + +import os +import sys + +try: + from PIL import Image +except ImportError: + sys.exit("extractFlame.py: needs Pillow (pip install pillow)") + +HERE = os.path.dirname(os.path.abspath(__file__)) +RAW = os.path.join(HERE, "..", "..", "..", "stuff", "spacetaxi", "raw.bin") +PNG = os.path.join(HERE, "sprites", "sprites.png") + +CEL = 24 # cel is 24x24 px +LOAD_HDR = 2 # raw.bin 2-byte load-address header + +# flameCels index -> C64 sprite block (from $6DB0 / kFlameCelByDirMask). +FLAME_BLOCKS = [0xD8, 0xD4, 0xD5, 0xD2, 0xD1, 0xD7, 0xD3, 0xD6] + +# Multicolor 2-bit pixel -> palette index. The engine renders sprite +# cels through kDefaultPalette (stRender.c), whose index IS the C64 VIC-II +# colour code -- the sprite's own baked palette is ignored (loadSpriteSheet +# passes NULL). So the C64 flame colours map DIRECTLY by code: +# 00 transparent(0), 01 MC0=$D025=$02 red(2), 11 MC1=$D026=$07 yellow(7), +# 10 sprite-2 colour -> orange(8) (barely used by these cels). +# (NOT the sprites.png preview-palette indices, which differ.) +MC_TO_INDEX = {0: 0, 1: 2, 2: 8, 3: 7} + +FLAME_ROW_Y = 2 * CEL # sheet row 2 (flame row) top edge + + +def loadRaw(): + with open(RAW, "rb") as f: + return f.read() + + +def celFromBlock(raw, block): + # Return a 24x24 list-of-rows of palette indices for one flame block. + base = LOAD_HDR + block * 64 + cel = [[0] * CEL for _ in range(CEL)] + for row in range(21): # C64 sprite is 21 rows tall + for byteIdx in range(3): # 3 bytes/row + b = raw[base + row * 3 + byteIdx] + for pair in range(4): # 4 MC pixels per byte (MSB first) + mc = (b >> ((3 - pair) * 2)) & 3 + idx = MC_TO_INDEX[mc] + px = (byteIdx * 4 + pair) * 2 # MC pixel -> 2 cel pixels + cel[row][px] = idx + cel[row][px + 1] = idx + return cel + + +def main(): + raw = loadRaw() + im = Image.open(PNG) + if im.mode != "P": + sys.exit(f"extractFlame.py: {PNG} is {im.mode}, expected indexed 'P'") + px = im.load() + nonEmpty = 0 + for celIdx, block in enumerate(FLAME_BLOCKS): + cel = celFromBlock(raw, block) + x0 = celIdx * CEL + opaque = 0 + for y in range(CEL): + for x in range(CEL): + v = cel[y][x] + px[x0 + x, FLAME_ROW_Y + y] = v + if v != 0: + opaque += 1 + nonEmpty += 1 if opaque else 0 + print(f" flameCels[{celIdx}] block ${block:02X}: {opaque} opaque px -> cel {18 + celIdx}") + im.save(PNG) + print(f"extractFlame: patched {nonEmpty}/8 flame cels into {os.path.relpath(PNG, HERE)}") + + +if __name__ == "__main__": + main() diff --git a/assets/genDemoStreams.py b/assets/genDemoStreams.py new file mode 100644 index 0000000..9517557 --- /dev/null +++ b/assets/genDemoStreams.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# Embed the C64 attract-demo recorded input streams (the four +# "SPACETAXI 0x" disk files -- RLE [joystick mask][tick count] pairs; +# mask bit0=UP bit1=DOWN bit2=LEFT bit3=RIGHT, bit7 always set, no +# fire bit -- which is why every recorded ride ends in a crash: the +# demo cab can never lower its gear) as C arrays for stDemoStreams.h. +# +# Every stream file is odd-length: the $9936 saver's end=$3F+3 +# off-by-one appends one dangling byte past the last complete pair, +# trimmed here on all four. +# +# Rotation order is H -> W -> T -> X ($6FE2/$50C1 trace) with file +# mapping H=01, T=02, W=03, X=04 and demo levels H=8, W=23, T=20, +# X=24 (1-based). + +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +EXTRACT = os.path.join(HERE, "..", "..", "..", "stuff", "spacetaxi", "extract") +OUT = os.path.join(HERE, "..", "stDemoStreams.h") + +# (file, symbol, 0-based level index, screen letter) in ROTATION order. +STREAMS = [ + ("SPACETAXI 01_N0S.prg", "kDemoStreamH", 7, "H"), + ("SPACETAXI 03_N0S.prg", "kDemoStreamW", 22, "W"), + ("SPACETAXI 02_N0S.prg", "kDemoStreamT", 19, "T"), + ("SPACETAXI 04_N0S.prg", "kDemoStreamX", 23, "X"), +] + + +def main(): + with open(OUT, "w", encoding="ascii") as f: + f.write("// Generated by assets/genDemoStreams.py -- do not hand-edit.\n") + f.write("// C64 attract-demo recorded input streams (see the generator\n") + f.write("// for format notes). Rotation order H, W, T, X.\n\n") + f.write("#ifndef ST_DEMO_STREAMS_H\n#define ST_DEMO_STREAMS_H\n\n") + f.write("#include \n\n") + entries = [] + for fname, sym, level, letter in STREAMS: + data = open(os.path.join(EXTRACT, fname), "rb").read() + if len(data) % 2 == 1: + data = data[:-1] # trim the saver's dangling byte + f.write(f"// {letter}: {fname} ({len(data)} bytes after trim)\n") + f.write(f"static const uint8_t {sym}[{len(data)}] = {{\n") + for i in range(0, len(data), 12): + row = ", ".join(f"0x{b:02X}" for b in data[i:i + 12]) + f.write(f" {row},\n") + f.write("};\n\n") + entries.append((sym, len(data), level)) + f.write("typedef struct {\n") + f.write(" const uint8_t *stream;\n") + f.write(" uint16_t length;\n") + f.write(" uint8_t levelIndex; // 0-based\n") + f.write("} StDemoEntryT;\n\n") + f.write(f"static const StDemoEntryT kDemoRotation[{len(entries)}] = {{\n") + for sym, ln, level in entries: + f.write(f" {{ {sym}, {ln}u, {level}u }},\n") + f.write("};\n\n#endif\n") + print(f"wrote {OUT}") + + +if __name__ == "__main__": + main() diff --git a/assets/genPlaceholderArt.py b/assets/genPlaceholderArt.py index d9c0f75..6b7e84a 100644 --- a/assets/genPlaceholderArt.py +++ b/assets/genPlaceholderArt.py @@ -119,13 +119,19 @@ def gen_tilebank(): def gen_sprites(): - """sprites.png: 320x200, layout: - y= 0..23 : taxi cels 0-3 (drive) + 4-8 (transporter shrink) - y= 24..47 : passenger walk cels (row 1) - y= 48..71 : flame row (left blank; real cels come from raw.bin) - Sheet is exactly the 9x3 grid of 24x24 cels (216x72) so assetbake's - cell grid divides evenly. - """ + """RETIRED 2026-07-20: sprites.png is now fully extracted from the + C64 dump by stuff/spacetaxi/extractSprites.py (taxi $C1/$C0/$DC/ + $DD, passenger $C4-$CB/$D9, flame $6DB0 table, warp $E2-$E8 -- + 216x96, 4 rows). Regenerating placeholder art here would clobber + the real sheet, the same trap gen_font has with the hand-edited + font.png. Refuses to run; use extractSprites.py instead.""" + print("gen_sprites: REFUSING -- sprites.png is real C64 art now.") + print(" regenerate via: python3 stuff/spacetaxi/extractSprites.py " + "stuff/spacetaxi/raw.bin examples/spacetaxi/assets/sprites/sprites.png") + return + + +def gen_sprites_retired(): img = new_canvas(9 * 24, 3 * 24) # 216x72: 9 cols x 3 rows of 24x24 d = ImageDraw.Draw(img) diff --git a/assets/sprites/sprites.png b/assets/sprites/sprites.png index 83a1e6a..85b7bb7 100644 Binary files a/assets/sprites/sprites.png and b/assets/sprites/sprites.png differ diff --git a/generated/amiga/sprites/sprites.spc b/generated/amiga/sprites/sprites.spc new file mode 100644 index 0000000..e256be6 --- /dev/null +++ b/generated/amiga/sprites/sprites.spc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5ccb0dfde5ec5a37c9a9b2c69139cc4f0dc6d4d11ecc40f1e4132d55b604e9b +size 393270 diff --git a/generated/amiga/sprites/sprites.spr b/generated/amiga/sprites/sprites.spr index 90ef8db..eaaadbc 100644 Binary files a/generated/amiga/sprites/sprites.spr and b/generated/amiga/sprites/sprites.spr differ diff --git a/generated/atarist/sprites/sprites.spc b/generated/atarist/sprites/sprites.spc new file mode 100644 index 0000000..997682a --- /dev/null +++ b/generated/atarist/sprites/sprites.spc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96333d839036e8af22f052107380f8ee9692d37b4f7de9044c070ce8194dc9fe +size 697490 diff --git a/generated/atarist/sprites/sprites.spr b/generated/atarist/sprites/sprites.spr index 90ef8db..eaaadbc 100644 Binary files a/generated/atarist/sprites/sprites.spr and b/generated/atarist/sprites/sprites.spr differ diff --git a/generated/dos/sprites/sprites.spc b/generated/dos/sprites/sprites.spc new file mode 100644 index 0000000..3141d20 --- /dev/null +++ b/generated/dos/sprites/sprites.spc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e12261031e8b47522e506a34f099509c313e063d57b3d8b7dc95c215069def2 +size 133445 diff --git a/generated/dos/sprites/sprites.spr b/generated/dos/sprites/sprites.spr index 90ef8db..eaaadbc 100644 Binary files a/generated/dos/sprites/sprites.spr and b/generated/dos/sprites/sprites.spr differ diff --git a/generated/iigs/sprites/sprites.spc b/generated/iigs/sprites/sprites.spc new file mode 100644 index 0000000..3b832e5 --- /dev/null +++ b/generated/iigs/sprites/sprites.spc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9762d47cb7f9b56fa2cac394c179947a0000f46140e227c24095f81e01b290c5 +size 95240 diff --git a/generated/iigs/sprites/sprites.spr b/generated/iigs/sprites/sprites.spr index 90ef8db..eaaadbc 100644 Binary files a/generated/iigs/sprites/sprites.spr and b/generated/iigs/sprites/sprites.spr differ diff --git a/spacetaxi.c b/spacetaxi.c index 45cd5f4..2f5e80b 100644 --- a/spacetaxi.c +++ b/spacetaxi.c @@ -19,33 +19,178 @@ #include #include "spacetaxi.h" +#include "stDemoStreams.h" + +// The C64 runs its physics + game logic once per GAME TICK, and a game +// tick is 2 video frames (the main loop waits on the raster twice per +// iteration, $5F94 + $5FA3) = ~30 Hz NTSC. The port renders at each +// platform's own frame rate (70 Hz DOS, 50 Hz ST/Amiga, ~15 fps IIgs), +// so gameplay is stepped on a fixed 33 ms accumulator rather than once +// per rendered frame. Without this the cab accelerates ~2.3x too fast +// on DOS and half-speed on the IIgs -- every per-tick constant (accel +// 14, gravity 1, the $7D8F templates, the demo RLE counts) is authored +// for this 30 Hz cadence, so only stepping at 30 Hz reproduces the +// original feel, identically on every port. +#define ST_GAME_TICK_MS 33u +// Cap catch-up ticks per rendered frame so a hitch (disk stall, cold +// cache) drops backlog instead of spiraling; ~165 ms simulated max. +#define ST_MAX_CATCHUP_TICKS 5u static void applyInput(StGameT *game); +static void demoEnter(StGameT *game); +static void demoExitToTitle(StGameT *game); +static void demoTick(StGameT *game); +static void demoTickInput(StGameT *game); static bool loadLevelByIndex(StGameT *game, uint8_t idx); +static void playingTick(StGameT *game); // 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", + "levels/level01.dat", "levels/level02.dat", + "levels/level03.dat", "levels/level04.dat", + "levels/level05.dat", "levels/level06.dat", + "levels/level07.dat", "levels/level08.dat", + "levels/level09.dat", "levels/level10.dat", + "levels/level11.dat", "levels/level12.dat", + "levels/level13.dat", "levels/level14.dat", + "levels/level15.dat", "levels/level16.dat", + "levels/level17.dat", "levels/level18.dat", + "levels/level19.dat", "levels/level20.dat", + "levels/level21.dat", "levels/level22.dat", + "levels/level23.dat", "levels/level24.dat", }; #define ST_LEVEL_COUNT (sizeof(gLevelPaths) / sizeof(gLevelPaths[0])) +// Attract-demo rotation cursor (mirrors $5143). Playback is stepped by +// the main loop's fixed game-tick accumulator, so no wall-clock here. +static uint8_t gDemoRotation; + + +// Enter (or advance to the next screen of) the rolling attract demo: +// pick the rotation slot, load its level, seed the recorded stream +// ($6262 seeds mask/timer from the head), zero the demo score. +static void demoEnter(StGameT *game) { + const StDemoEntryT *e = &kDemoRotation[gDemoRotation & 3u]; + + gDemoRotation++; + if (!loadLevelByIndex(game, e->levelIndex)) { + demoExitToTitle(game); + return; + } + stEngineReset(game); + game->score = 0u; + game->demoStream = e->stream; + game->demoLen = e->length; + game->demoMask = e->stream[0]; + game->demoTimer = e->stream[1]; + game->demoOff = 2u; + game->demoEnded = false; + game->state = ST_STATE_DEMO; +} + + +static void demoExitToTitle(StGameT *game) { + if (stLevelLoad(&game->level, "levels/title.dat")) { + stRenderLevelChanged(); + } + stRenderTitleReset(); // replay the intro -> attract cycle + stAudioSfxThrust(false); + game->state = ST_STATE_TITLE; +} + + +// Recorded-input playback, substituting for applyInput ($48F2): DEC +// the pair timer each game tick; at zero take the next [mask][count] +// pair. Mask bits 0-3 = UP/DOWN/LEFT/RIGHT (bit 7 is always set in +// the recordings, no fire bit -- the demo cab never lowers its gear, +// which is why every recorded ride ends in a crash). +static void demoTickInput(StGameT *game) { + StTaxiT *t = &game->taxi; + uint8_t mask; + bool up; + bool down; + bool left; + bool right; + + // Advance the RLE stream by exactly one game tick (the caller runs + // this once per fixed 33 ms step, in lockstep with stEngineTick). + if (game->demoTimer > 0u) { + game->demoTimer--; + } + if (game->demoTimer == 0u) { + if ((uint16_t)(game->demoOff + 1u) < game->demoLen) { + game->demoMask = game->demoStream[game->demoOff]; + game->demoTimer = game->demoStream[game->demoOff + 1u]; + game->demoOff = (uint16_t)(game->demoOff + 2u); + } else { + // The C64 has no end-of-stream check (every recorded ride + // crashes first); hold idle until then. + game->demoMask = 0x80u; + game->demoTimer = 0xFFu; + } + } + mask = game->demoMask; + up = (mask & 0x01u) != 0u; + down = (mask & 0x02u) != 0u; + left = (mask & 0x04u) != 0u; + right = (mask & 0x08u) != 0u; + 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); + t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) | + (left ? 4u : 0u) | (right ? 8u : 0u)); + if (left) { + t->facing = ST_DIR_LEFT; + } else if (right) { + t->facing = ST_DIR_RIGHT; + } +} + + +// One fixed game-tick of the attract demo: recorded input -> physics -> +// passenger. The main loop runs this in lockstep with the 30 Hz clock. +static void demoTick(StGameT *game) { + demoTickInput(game); + stEngineTick(game); + stPassengerTick(game); +} + + +// One fixed game-tick of PLAYING: warp recede, or input -> physics -> +// passenger, with the land-SFX edge and the transporter-exit trigger. +// The thrust SFX is left to the caller (once per rendered frame). +static void playingTick(StGameT *game) { + bool wasLanded; + + if (game->taxi.warpFrame > 0u) { + if (stEngineWarpTick(game)) { + stPassengerTransporterExit(game); + game->state = ST_STATE_LEVEL_DONE; + } + return; + } + wasLanded = game->taxi.landed; + applyInput(game); + stEngineTick(game); + stPassengerTick(game); + if (!wasLanded && game->taxi.landed) { + stAudioSfxLand(); + } + // Flying up through the top-wall transporter exits the screen: + // begin the shrink-warp (delivers a carried "UP PLEASE" fare and + // advances the screen when it ends). + if (game->taxi.crashTicks == 0u && stEngineInTransporter(game)) { + stEngineStartWarp(game); + } +} + + static void applyInput(StGameT *game) { StTaxiT *t = &game->taxi; int8_t jx; @@ -66,15 +211,36 @@ static void applyInput(StGameT *game) { 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. + // FIRE is the LANDING GEAR ($63DD): a press EDGE while airborne + // toggles the gear with an SFX; pressing UP while parked on a pad + // is the takeoff, which retracts the gear ($65AD AND #$FE). (The + // old comment here claiming "fire is never tested in this path" + // was wrong -- $63F1-$6401 tests it every tick.) + { + bool fire = jlKeyDown(KEY_SPACE) || jlJoyDown(JOYSTICK_0, JOY_BUTTON_0); + if (fire && !t->fireHeld && !t->landed) { + t->gearDown = !t->gearDown; + stAudioSfxGear(); + } + t->fireHeld = fire; + } + if (up && t->landed) { + t->gearDown = false; // takeoff retracts the gear + } + // Gear down strips lateral thrust BEFORE the direction mask is + // built ($6190 AND #$13 runs upstream of the $60D6 mask store), + // so the flame table never sees L/R with the gear down either. + if (t->gearDown) { + left = false; + right = false; + } 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); + // $716A-style mask for the flame cel table (1=UP 2=DOWN 4=LEFT + // 8=RIGHT, same encoding kFlameCelByDirMask indexes). + t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) | + (left ? 4u : 0u) | (right ? 8u : 0u)); // Facing is purely cosmetic (sprite cel selection). Update when // horizontal thrust is commanded; keep last facing otherwise so @@ -103,14 +269,26 @@ static bool loadLevelByIndex(StGameT *game, uint8_t idx) { int main(void) { jlConfigT config; jlSurfaceT *stage; - StGameT game; + // game embeds a full StLevelT (tilemap + colormap = ~2 KB). As a + // stack local it held ~2 KB across the whole program, overflowing the + // IIgs (llvm-mos) soft stack once the deep asset-load call chain ran + // (stRenderInit -> loadSpriteSheet -> jlSpriteBankLoad -> fopen) and + // silently crashing back to GS/OS. It is a single program-lifetime + // instance, so static (BSS) is both correct and stack-friendly. + static 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; + // Sprite codegen: the 26 drawn cels (taxi/warp/passenger/flame, + // 24x24 = 3x3-tile) each compile to per-shift save/draw/restore asm. + // Compiling them is what makes sprite blitting fast (the interpreter + // costs ~200 ms/frame on the IIgs 65816). The arena is capped at one + // 64 KB bank (codegenArena attrNoCross); 60 KB fits all 26 cels with + // headroom. The old 32 KB was sized for procedural sprites only. + config.codegenBytes = 60UL * 1024; config.audioBytes = 32UL * 1024; // music + SFX if (!jlInit(&config)) { @@ -145,7 +323,7 @@ int main(void) { // 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")) { + if (stLevelLoad(&game.level, "levels/title.dat")) { jlLogF("spacetaxi: title loaded, tilebank=%u", (unsigned)game.level.tileBankId); stRenderLevelChanged(); @@ -154,12 +332,39 @@ int main(void) { } jlLogFlush(); + // Fixed-timestep gameplay clock. gTickAccumMs banks real elapsed + // time; each frame consumes it in whole 30 Hz game ticks so physics + // advance at the C64's rate regardless of the render frame rate. + uint32_t gPrevTickMs = jlMillisElapsed(); + uint32_t gTickAccumMs = 0u; + for (;;) { + uint8_t pendingTicks; + uint8_t tick; + jlInputPoll(); if (jlKeyPressed(KEY_ESCAPE)) { break; } + { + uint32_t nowMs = jlMillisElapsed(); + uint32_t want; + gTickAccumMs += (uint32_t)(nowMs - gPrevTickMs); + gPrevTickMs = nowMs; + want = gTickAccumMs / ST_GAME_TICK_MS; // wide: no cast wrap + if (want > ST_MAX_CATCHUP_TICKS) { + // A long stall (level load, disk seek): resync to now + // and skip the gap instead of spiraling. Zero the whole + // accumulator so no backlog survives the truncated cast. + want = ST_MAX_CATCHUP_TICKS; + gTickAccumMs = 0u; + } else { + gTickAccumMs -= want * ST_GAME_TICK_MS; // keep sub-tick remainder + } + pendingTicks = (uint8_t)want; // guaranteed <= 5 + } + switch (game.state) { case ST_STATE_TITLE: { @@ -185,7 +390,56 @@ int main(void) { jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) { game.fareTarget = 1u; // C64 default ($48AF sets 1) game.state = ST_STATE_OPTIONS; + break; } + // Advance the sparkle -> walk -> takeoff intro (and the + // logo flip) on the fixed 30 Hz clock, so pre-game + // timing is identical on every port. + for (tick = 0u; + tick < pendingTicks && game.state == ST_STATE_TITLE; + tick++) { + stRenderTitleAdvance(); + } + // Intro finished (cab took off): roll the attract + // demo, exactly like the C64's post-takeoff flow. + if (stRenderTitleIntroDone()) { + demoEnter(&game); + } + break; + } + case ST_STATE_DEMO: + { + // Any real input exits the attract loop ($5006 tests + // $DC00 != $7F). + int8_t jx = jlJoystickX(JOYSTICK_0); + int8_t jy = jlJoystickY(JOYSTICK_0); + if (jlKeyPressed(KEY_SPACE) || + jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) || + jlKeyDown(KEY_UP) || jlKeyDown(KEY_DOWN) || + jlKeyDown(KEY_LEFT) || jlKeyDown(KEY_RIGHT) || + jx < -32 || jx > 32 || jy < -32 || jy > 32) { + demoExitToTitle(&game); + break; + } + if (game.demoEnded) { + // The recorded ride crashed out; $6B57 skips the + // life bookkeeping in demo mode -- back to title. + demoExitToTitle(&game); + break; + } + if (game.score != 0u) { + // Fare delivered: advance the rotation ($501D -> + // $50C6) without visiting the title. + demoEnter(&game); + break; + } + for (tick = 0u; + tick < pendingTicks && game.state == ST_STATE_DEMO && + !game.demoEnded && game.score == 0u; + tick++) { + demoTick(&game); + } + stAudioSfxThrust(game.taxi.thrusting); break; } case ST_STATE_HIGHSCORES: @@ -196,6 +450,7 @@ int main(void) { if (jlKeyPressed(KEY_SPACE) || jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) { stRenderLevelChanged(); + stRenderTitleReset(); game.state = ST_STATE_TITLE; } break; @@ -244,36 +499,18 @@ int main(void) { break; } case ST_STATE_PLAYING: - { - bool wasLanded; - // Transporter shrink-warp in progress: suspend input and - // physics, just advance the recede animation; when it - // finishes, deliver any "UP PLEASE" fare and change screens. - if (game.taxi.warpFrame > 0u) { - if (stEngineWarpTick(&game)) { - stPassengerTransporterExit(&game); - game.state = ST_STATE_LEVEL_DONE; - } - break; + // Advance physics at the fixed 30 Hz cadence -- each + // playingTick handles the warp recede, physics, passenger, + // land SFX, and transporter-exit trigger. Stop early if a + // tick changed state (crash game-over, level done). + for (tick = 0u; + tick < pendingTicks && game.state == ST_STATE_PLAYING; + tick++) { + playingTick(&game); } - wasLanded = game.taxi.landed; - applyInput(&game); - stEngineTick(&game); - stPassengerTick(&game); - // Edge-trigger SFX from state deltas. + // Continuous thrust SFX reflects the final tick's state. stAudioSfxThrust(game.taxi.thrusting); - if (!wasLanded && game.taxi.landed) { - stAudioSfxLand(); - } - // Flying up through the top-wall transporter exits the - // screen: begin the shrink-warp (which delivers a carried - // "UP PLEASE" fare and advances the screen when it ends). - if (game.taxi.crashTicks == 0u && - stEngineInTransporter(&game)) { - stEngineStartWarp(&game); - } 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; @@ -283,9 +520,10 @@ int main(void) { game.state = ST_STATE_PLAYING; } else { // ran out of levels -- back to title (player wins) - if (stLevelLoad(&game.level, "DATA/levels/title.dat")) { + if (stLevelLoad(&game.level, "levels/title.dat")) { stRenderLevelChanged(); } + stRenderTitleReset(); game.state = ST_STATE_TITLE; } break; @@ -294,9 +532,10 @@ int main(void) { // 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")) { + if (stLevelLoad(&game.level, "levels/title.dat")) { stRenderLevelChanged(); } + stRenderTitleReset(); game.state = ST_STATE_TITLE; } break; @@ -305,9 +544,13 @@ int main(void) { break; } - // Silence continuous thrust SFX whenever we're not actively - // playing (title, level-intro, game-over, level-done). - if (game.state != ST_STATE_PLAYING) { + // Silence continuous thrust SFX whenever no cab is flying + // under thrust (title, menus, game-over, level-done). The + // attract DEMO is a recorded gameplay session and thrusts + // audibly, so it keeps its own per-tick thrust SFX -- without + // this exclusion the blanket silence overrode it and the demo + // cab flamed in total silence. + if (game.state != ST_STATE_PLAYING && game.state != ST_STATE_DEMO) { stAudioSfxThrust(false); } diff --git a/spacetaxi.h b/spacetaxi.h index 7bbb62b..791f05d 100644 --- a/spacetaxi.h +++ b/spacetaxi.h @@ -20,8 +20,11 @@ // 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. +// input Joystick port 2 conventionally on the C64: direction = +// the four cardinals (thrust), FIRE = the LANDING GEAR +// toggle while airborne ($63DD: press edge EORs $7197 +// bit 0 with an SFX; $6462 requires gear down to land; +// $6190 strips left/right thrust while gear is down). // Keyboard fallback: arrows + space for hosts without // joysticks. // @@ -96,15 +99,10 @@ #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 +// Taxi cels are the real C64 art (extractSprites.py): the $C0/$C1 +// right-facing and $DC/$DD left-facing pairs with the $6A95 per-frame +// low-bit flicker; the engine flame is the separate direction-indexed +// sprite-2 cel set. Selection lives in stRender.c. typedef enum { @@ -112,6 +110,7 @@ typedef enum { ST_STATE_HIGHSCORES, // UP on title -> "THE IMMORTAL CABBIES" ($4C1F) ST_STATE_INSTRUCTIONS, // DOWN on title -> author/about screen ($556A) ST_STATE_OPTIONS, // "GAME VARIATIONS" menu ($5295): pick cabbie count + ST_STATE_DEMO, // rolling attract demo (recorded input playback) ST_STATE_PLAYING, ST_STATE_LEVEL_DONE, ST_STATE_GAME_OVER @@ -188,10 +187,19 @@ typedef struct { 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 + // Held-direction bit mask, mirroring $716A's encoding (the index + // into the $6DB0 flame cel table): 1=UP 2=DOWN 4=LEFT 8=RIGHT. + // With gear down the left/right bits are stripped before this is + // built, exactly like $6190 AND #$13 feeding $60D6. + uint8_t dirMask; + // Landing gear ($7197 bit 0). Toggled by a FIRE press edge while + // airborne, retracted on takeoff ($65B0), REQUIRED down to land + // ($6462) -- gear-up contact with a pad is a crash. + bool gearDown; + bool fireHeld; // previous-frame fire state (edge detect) 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 @@ -200,6 +208,9 @@ typedef struct { // branch"). Reaches 0 -> respawn (or game-over). Mirrors the // C64's $6B24/$6B4C phase-2/3 timing. uint8_t crashTicks; + // Crash phase split ($6A72 fall vs $6B2A impact walk): false while + // the debris tumbles, true once it hits the floor. + bool crashImpacted; // Transporter shrink-warp progress (0 = not warping; 1..64 = the // takeoff/shrink animation $5C91/$5CB9). During the warp the taxi // recedes up through the opening; the screen advances when it ends. @@ -207,15 +218,40 @@ typedef struct { } StTaxiT; +// Passenger lifecycle phases (see the field comments in StPassengerT +// for the C64 sequences each one mirrors). +typedef enum { + ST_PASS_BEAM_IN = 0, // materialize at standX ($CB..$C7) + ST_PASS_WAIT, // stand + wave ($66D6 cycle) + ST_PASS_WALK_TO_CAB, // walk to the captured taxi X + ST_PASS_BOARD_OUT, // shrink into the cab ($C7..$CB) + ST_PASS_RIDING, // aboard, invisible + ST_PASS_DROP_IN, // delivery: materialize beside the cab + ST_PASS_WALK_TO_PAD, // walk to the destination standX + ST_PASS_DROP_OUT // shrink out -- fare complete +} StPassPhaseE; + + 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 x; // pixel position int16_t y; - uint8_t walkPhase; // animation cel (0..3) - uint8_t walkDir; // 0 = walking right, 1 = walking left + // Passenger lifecycle, per the traced sequences: materialize + // ($CB..$C7 grow-in), wave in place ($66D6 cycle -- he NEVER + // walks while waiting), walk to the landed cab ($6811 captures + // the taxi X once; $C4/$C5 leftward, $C2/$C3 rightward, +/-2 px + // per 3-tick step), shrink into the cab ($C7..$CB), ride, and + // the delivery mirror (materialize at the cab, walk to standX, + // shrink out). + StPassPhaseE phase; + int16_t targetX; // walk destination (captured once) + // Step counter within the current phase; in ST_PASS_WAIT it is + // the 0..3 wave-cycle index, in the walk phases its low bit is + // the stride cel alternation. + uint8_t waitPhase; } StPassengerT; @@ -227,6 +263,15 @@ typedef struct { uint32_t score; uint8_t lives; uint8_t levelIndex; + // Attract-demo playback state (ST_STATE_DEMO): the recorded RLE + // input stream and its cursor, plus the crash-finished signal the + // engine raises instead of decrementing lives ($6B57). + const uint8_t *demoStream; + uint16_t demoLen; + uint16_t demoOff; + uint8_t demoTimer; + uint8_t demoMask; + bool demoEnded; // Player-selectable fare quota (1..4). Mirrors C64 $7213 (the // GAME-VARIATIONS "cabbies" count), set on the title screen via // LEFT/RIGHT joystick ($5310-$5328). This is the GLOBAL number of @@ -249,6 +294,9 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game); // after stLevelLoad even when the StLevelT pointer is unchanged -- // stRenderLevel's dirty-cache compares pointers, not contents. void stRenderLevelChanged(void); +void stRenderTitleAdvance(void); +bool stRenderTitleIntroDone(void); +void stRenderTitleReset(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. @@ -278,6 +326,7 @@ void stAudioFrameTick(void); // call once per host frame; counts down SFX void stAudioPlayMusic(uint8_t musicId); void stAudioStopMusic(void); void stAudioSfxThrust(bool on); +void stAudioSfxGear(void); void stAudioSfxLand(void); void stAudioSfxPickup(void); void stAudioSfxDropoff(void); diff --git a/stAudio.c b/stAudio.c index 00cdfe4..4f06c01 100644 --- a/stAudio.c +++ b/stAudio.c @@ -34,6 +34,8 @@ #define ST_SFX_DROPOFF_TICKS 25u #define ST_SFX_LAND_HZ 90u #define ST_SFX_LAND_TICKS 12u +#define ST_SFX_GEAR_HZ 140u // approximation -- $6410 table untraced +#define ST_SFX_GEAR_TICKS 5u // C64-matched SFX behavior (see MECHANICS.md "Sound (SID)"): // @@ -191,6 +193,15 @@ void stAudioSfxThrust(bool on) { } +void stAudioSfxGear(void) { + // $63FC-$640C: the gear toggle fires SFX ptr $6410 through $42E9. + // The exact $6410 waveform is untraced; a short low click stands + // in until that SFX table is decoded -- marked approximation, not + // ROM-derived. + armSfx(ST_SFX_GEAR_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_GEAR_TICKS); +} + + void stAudioSfxLand(void) { armSfx(ST_SFX_LAND_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_LAND_TICKS); } diff --git a/stDemoStreams.h b/stDemoStreams.h new file mode 100644 index 0000000..dc71e8f --- /dev/null +++ b/stDemoStreams.h @@ -0,0 +1,171 @@ +// Generated by assets/genDemoStreams.py -- do not hand-edit. +// C64 attract-demo recorded input streams (see the generator +// for format notes). Rotation order H, W, T, X. + +#ifndef ST_DEMO_STREAMS_H +#define ST_DEMO_STREAMS_H + +#include + +// H: SPACETAXI 01_N0S.prg (296 bytes after trim) +static const uint8_t kDemoStreamH[296] = { + 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, 0x89, 0x01, 0x88, 0x07, 0x8A, 0x04, 0x82, 0x0F, + 0x80, 0x07, 0x81, 0x04, 0x80, 0x03, 0x82, 0x03, 0x86, 0x09, 0x84, 0x08, + 0x81, 0x03, 0x89, 0x06, 0x80, 0x01, 0x86, 0x08, 0x82, 0x01, 0x80, 0x03, + 0x88, 0x03, 0x80, 0x04, 0x95, 0x07, 0x85, 0x0A, +}; + +// W: SPACETAXI 03_N0S.prg (334 bytes after trim) +static const uint8_t kDemoStreamW[334] = { + 0x80, 0x0E, 0x82, 0x05, 0x8A, 0x01, 0x88, 0x0F, 0x89, 0x06, 0x81, 0x06, + 0x85, 0x0D, 0x84, 0x07, 0x86, 0x0C, 0x82, 0x01, 0x88, 0x09, 0x89, 0x02, + 0x81, 0x02, 0x80, 0x05, 0x88, 0x04, 0x80, 0x06, 0x84, 0x04, 0x80, 0x02, + 0x88, 0x13, 0x81, 0x01, 0x80, 0x01, 0x84, 0x0B, 0x85, 0x07, 0x80, 0x08, + 0x88, 0x14, 0x80, 0x02, 0x84, 0x0F, 0x80, 0x0D, 0x84, 0x05, 0x85, 0x03, + 0x80, 0x04, 0x88, 0x16, 0x81, 0x01, 0x80, 0x03, 0x84, 0x02, 0x86, 0x12, + 0x80, 0x09, 0x81, 0x18, 0x80, 0x0B, 0x81, 0x05, 0x85, 0x06, 0x84, 0x08, + 0x86, 0x0A, 0x84, 0x01, 0x80, 0x01, 0x81, 0x02, 0x89, 0x0B, 0x88, 0x0C, + 0x80, 0x17, 0x82, 0x01, 0x86, 0x0B, 0x84, 0x02, 0x85, 0x09, 0x81, 0x01, + 0x89, 0x01, 0x88, 0x1E, 0x80, 0x03, 0x84, 0x02, 0x85, 0x06, 0x95, 0x02, + 0x94, 0x04, 0x90, 0x01, 0x80, 0x09, 0x82, 0x06, 0x80, 0x07, 0x81, 0x04, + 0x80, 0x0E, 0x81, 0x01, 0x80, 0x38, 0x81, 0x07, 0x89, 0x01, 0x88, 0x05, + 0x80, 0x0C, 0x84, 0x01, 0x85, 0x05, 0x81, 0x01, 0x80, 0x10, 0x88, 0x04, + 0x8A, 0x0E, 0x88, 0x02, 0x89, 0x04, 0x81, 0x04, 0x85, 0x01, 0x84, 0x22, + 0x81, 0x03, 0x89, 0x0A, 0x81, 0x01, 0x80, 0x0E, 0x82, 0x08, 0x8A, 0x08, + 0x88, 0x09, 0x89, 0x02, 0x81, 0x07, 0x85, 0x03, 0x84, 0x03, 0x85, 0x0F, + 0x81, 0x02, 0x89, 0x06, 0x88, 0x04, 0x80, 0x02, 0x84, 0x06, 0x86, 0x03, + 0x82, 0x05, 0x80, 0x01, 0x89, 0x07, 0x88, 0x02, 0x80, 0x04, 0x86, 0x16, + 0x84, 0x02, 0x85, 0x03, 0x81, 0x04, 0x89, 0x09, 0x88, 0x05, 0x80, 0x11, + 0x88, 0x02, 0x8A, 0x0A, 0x82, 0x01, 0x80, 0x04, 0x84, 0x08, 0x85, 0x12, + 0x84, 0x06, 0x81, 0x01, 0x89, 0x07, 0x88, 0x07, 0x8A, 0x14, 0x82, 0x02, + 0x86, 0x01, 0x84, 0x03, 0x85, 0x07, 0x95, 0x03, 0x91, 0x02, 0x80, 0x0E, + 0x98, 0x04, 0x99, 0x01, 0x89, 0x06, 0x81, 0x03, 0x85, 0x02, 0x84, 0x03, + 0x86, 0x01, 0x82, 0x04, 0x8A, 0x01, 0x88, 0x03, 0x80, 0x05, 0x90, 0x01, + 0x94, 0x05, 0x84, 0x01, 0x80, 0x08, 0x84, 0x05, 0x80, 0x78, 0x81, 0x0D, + 0x80, 0x07, 0x82, 0x06, 0x80, 0x2D, 0x88, 0x08, 0x8A, 0x02, 0x88, 0x0C, + 0x80, 0x02, 0x84, 0x06, 0x85, 0x0F, 0x81, 0x02, 0x89, 0x04, 0x88, 0x07, + 0x8A, 0x0B, 0x82, 0x05, 0x86, 0x03, 0x84, 0x07, 0x85, 0x03, +}; + +// T: SPACETAXI 02_N0S.prg (374 bytes after trim) +static const uint8_t kDemoStreamT[374] = { + 0x89, 0x0B, 0x88, 0x03, 0x80, 0x04, 0x86, 0x05, 0x84, 0x07, 0x80, 0x01, + 0x81, 0x0A, 0x80, 0x06, 0x86, 0x02, 0x84, 0x02, 0x80, 0x09, 0x84, 0x04, + 0x80, 0x04, 0x81, 0x01, 0x89, 0x04, 0x81, 0x01, 0x80, 0x0B, 0x84, 0x02, + 0x80, 0x18, 0x81, 0x06, 0x80, 0x09, 0x88, 0x04, 0x80, 0x09, 0x81, 0x04, + 0x80, 0x0C, 0x81, 0x07, 0x80, 0x0E, 0x88, 0x05, 0x80, 0x07, 0x81, 0x05, + 0x85, 0x02, 0x81, 0x05, 0x85, 0x01, 0x84, 0x05, 0x80, 0x04, 0x81, 0x0B, + 0x80, 0x0B, 0x81, 0x0B, 0x80, 0x0A, 0x81, 0x09, 0x80, 0x06, 0x88, 0x0C, + 0x80, 0x0D, 0x84, 0x05, 0x80, 0x0C, 0x81, 0x07, 0x80, 0x05, 0x84, 0x03, + 0x80, 0x0D, 0x85, 0x04, 0x81, 0x0C, 0x80, 0x0C, 0x84, 0x03, 0x80, 0x06, + 0x81, 0x0C, 0x80, 0x0A, 0x81, 0x0A, 0x80, 0x06, 0x81, 0x03, 0x85, 0x04, + 0x81, 0x01, 0x80, 0x07, 0x88, 0x05, 0x8A, 0x04, 0x80, 0x04, 0x88, 0x05, + 0x89, 0x0B, 0x81, 0x06, 0x80, 0x07, 0x82, 0x05, 0x80, 0x03, 0x81, 0x08, + 0x80, 0x02, 0x84, 0x05, 0x86, 0x04, 0x82, 0x01, 0x80, 0x08, 0x85, 0x12, + 0x81, 0x09, 0x89, 0x04, 0x81, 0x03, 0x85, 0x09, 0x81, 0x02, 0x80, 0x13, + 0x81, 0x04, 0x80, 0x0D, 0x81, 0x02, 0x80, 0x06, 0x84, 0x04, 0x80, 0x04, + 0x81, 0x03, 0x80, 0x03, 0x81, 0x03, 0x80, 0x05, 0x82, 0x04, 0x80, 0x07, + 0x81, 0x0B, 0x80, 0x03, 0x86, 0x03, 0x82, 0x01, 0x80, 0x14, 0x81, 0x03, + 0x80, 0x06, 0x81, 0x05, 0x80, 0x0C, 0x82, 0x03, 0x80, 0x04, 0x81, 0x06, + 0x80, 0x07, 0x82, 0x02, 0x80, 0x06, 0x81, 0x04, 0x80, 0x07, 0x88, 0x05, + 0x80, 0x08, 0x88, 0x04, 0x80, 0x06, 0x91, 0x02, 0x95, 0x03, 0x85, 0x01, + 0x84, 0x02, 0x80, 0x04, 0x82, 0x05, 0x80, 0x41, 0x81, 0x05, 0x80, 0x02, + 0x84, 0x06, 0x80, 0x07, 0x82, 0x04, 0x80, 0x05, 0x81, 0x08, 0x80, 0x10, + 0x81, 0x02, 0x80, 0x06, 0x82, 0x01, 0x80, 0x0D, 0x88, 0x07, 0x80, 0x0C, + 0x84, 0x04, 0x80, 0x14, 0x88, 0x03, 0x80, 0x1E, 0x81, 0x0C, 0x89, 0x01, + 0x88, 0x05, 0x80, 0x08, 0x84, 0x05, 0x80, 0x09, 0x88, 0x08, 0x89, 0x0F, + 0x88, 0x03, 0x8A, 0x05, 0x82, 0x01, 0x80, 0x02, 0x81, 0x07, 0x85, 0x0C, + 0x81, 0x01, 0x80, 0x01, 0x88, 0x01, 0x8A, 0x02, 0x82, 0x02, 0x80, 0x03, + 0x84, 0x0C, 0x86, 0x04, 0x84, 0x02, 0x80, 0x17, 0x91, 0x05, 0x80, 0x07, + 0x82, 0x03, 0x80, 0x5D, 0x81, 0x08, 0x80, 0x05, 0x82, 0x05, 0x80, 0x04, + 0x81, 0x01, 0x85, 0x02, 0x84, 0x07, 0x81, 0x04, 0x80, 0x06, 0x88, 0x06, + 0x80, 0x0E, 0x81, 0x06, 0x80, 0x0D, 0x81, 0x0D, 0x84, 0x05, 0x85, 0x0F, + 0x81, 0x06, +}; + +// X: SPACETAXI 04_N0S.prg (564 bytes after trim) +static const uint8_t kDemoStreamX[564] = { + 0x84, 0x07, 0x85, 0x06, 0x84, 0x06, 0x80, 0x0A, 0x81, 0x05, 0x80, 0x0C, + 0x88, 0x07, 0x81, 0x05, 0x88, 0x08, 0x81, 0x07, 0x80, 0x07, 0x88, 0x04, + 0x8A, 0x02, 0x88, 0x01, 0x80, 0x0A, 0x81, 0x07, 0x80, 0x08, 0x81, 0x07, + 0x85, 0x01, 0x84, 0x06, 0x80, 0x0E, 0x81, 0x06, 0x80, 0x0C, 0x88, 0x01, + 0x80, 0x0C, 0x81, 0x07, 0x80, 0x0D, 0x88, 0x07, 0x80, 0x01, 0x81, 0x06, + 0x80, 0x06, 0x84, 0x03, 0x80, 0x08, 0x81, 0x02, 0x80, 0x08, 0x81, 0x05, + 0x80, 0x0B, 0x88, 0x04, 0x80, 0x05, 0x91, 0x06, 0x80, 0x44, 0x81, 0x07, + 0x85, 0x01, 0x84, 0x0C, 0x80, 0x0D, 0x81, 0x05, 0x80, 0x0C, 0x81, 0x05, + 0x80, 0x1A, 0x88, 0x0C, 0x80, 0x16, 0x89, 0x06, 0x81, 0x0A, 0x80, 0x07, + 0x81, 0x05, 0x80, 0x0C, 0x81, 0x01, 0x89, 0x02, 0x88, 0x01, 0x89, 0x08, + 0x88, 0x03, 0x8A, 0x07, 0x88, 0x03, 0x80, 0x05, 0x81, 0x09, 0x84, 0x09, + 0x85, 0x02, 0x81, 0x03, 0x80, 0x12, 0x81, 0x05, 0x80, 0x04, 0x84, 0x04, + 0x80, 0x06, 0x81, 0x05, 0x85, 0x01, 0x84, 0x08, 0x86, 0x04, 0x84, 0x03, + 0x94, 0x04, 0x95, 0x01, 0x85, 0x07, 0x81, 0x05, 0x85, 0x02, 0x84, 0x01, + 0x80, 0x02, 0x82, 0x02, 0x8A, 0x07, 0x82, 0x01, 0x80, 0x07, 0x90, 0x01, + 0x91, 0x04, 0x80, 0x0B, 0x81, 0x04, 0x80, 0x44, 0x81, 0x09, 0x89, 0x05, + 0x88, 0x07, 0x8A, 0x05, 0x82, 0x01, 0x80, 0x11, 0x81, 0x03, 0x80, 0x0A, + 0x81, 0x08, 0x80, 0x06, 0x81, 0x04, 0x80, 0x0C, 0x81, 0x05, 0x80, 0x05, + 0x84, 0x03, 0x86, 0x01, 0x82, 0x01, 0x80, 0x07, 0x84, 0x06, 0x80, 0x05, + 0x84, 0x0A, 0x80, 0x08, 0x89, 0x04, 0x81, 0x01, 0x80, 0x06, 0x84, 0x01, + 0x85, 0x06, 0x81, 0x01, 0x80, 0x0B, 0x81, 0x05, 0x80, 0x04, 0x81, 0x07, + 0x80, 0x07, 0x84, 0x03, 0x85, 0x04, 0x84, 0x02, 0x80, 0x06, 0x81, 0x04, + 0x88, 0x02, 0x80, 0x05, 0x88, 0x06, 0x80, 0x08, 0x81, 0x03, 0x80, 0x08, + 0x89, 0x05, 0x88, 0x03, 0x89, 0x01, 0x80, 0x0F, 0x84, 0x04, 0x80, 0x03, + 0x81, 0x02, 0x91, 0x03, 0x80, 0x22, 0x85, 0x0C, 0x81, 0x05, 0x80, 0x02, + 0x82, 0x07, 0x80, 0x02, 0x82, 0x04, 0x80, 0x07, 0x81, 0x03, 0x84, 0x08, + 0x85, 0x05, 0x81, 0x03, 0x80, 0x0E, 0x81, 0x06, 0x88, 0x02, 0x80, 0x05, + 0x88, 0x01, 0x89, 0x08, 0x88, 0x01, 0x80, 0x06, 0x82, 0x06, 0x80, 0x05, + 0x81, 0x05, 0x80, 0x09, 0x81, 0x04, 0x80, 0x06, 0x81, 0x07, 0x80, 0x08, + 0x81, 0x03, 0x80, 0x09, 0x82, 0x02, 0x8A, 0x01, 0x88, 0x02, 0x80, 0x07, + 0x88, 0x09, 0x80, 0x11, 0x89, 0x02, 0x81, 0x02, 0x80, 0x04, 0x81, 0x0A, + 0x80, 0x05, 0x81, 0x01, 0x89, 0x08, 0x81, 0x04, 0x88, 0x01, 0x8A, 0x01, + 0x82, 0x06, 0x80, 0x05, 0x81, 0x08, 0x80, 0x0B, 0x81, 0x05, 0x88, 0x04, + 0x89, 0x03, 0x81, 0x03, 0x80, 0x22, 0x84, 0x06, 0x85, 0x07, 0x84, 0x0A, + 0x85, 0x01, 0x81, 0x03, 0x91, 0x02, 0x90, 0x01, 0x80, 0x01, 0x84, 0x04, + 0x80, 0x85, 0x81, 0x05, 0x85, 0x01, 0x84, 0x06, 0x85, 0x05, 0x84, 0x0B, + 0x80, 0x1B, 0x89, 0x0D, 0x80, 0x08, 0x82, 0x04, 0x80, 0x0D, 0x81, 0x0B, + 0x80, 0x06, 0x88, 0x01, 0x89, 0x09, 0x81, 0x07, 0x84, 0x04, 0x80, 0x02, + 0x81, 0x03, 0x89, 0x05, 0x80, 0x05, 0x82, 0x09, 0x8A, 0x01, 0x88, 0x0D, + 0x8A, 0x03, 0x88, 0x0B, 0x89, 0x08, 0x81, 0x03, 0x85, 0x06, 0x81, 0x01, + 0x80, 0x03, 0x82, 0x05, 0x86, 0x06, 0x84, 0x07, 0x95, 0x07, 0x85, 0x04, + 0x80, 0x36, 0x81, 0x02, 0x85, 0x07, 0x84, 0x0F, 0x80, 0x13, 0x81, 0x04, + 0x89, 0x04, 0x88, 0x03, 0x80, 0x05, 0x81, 0x02, 0x80, 0x14, 0x81, 0x06, + 0x80, 0x08, 0x88, 0x0A, 0x80, 0x04, 0x88, 0x01, 0x89, 0x09, 0x80, 0x06, + 0x84, 0x04, 0x80, 0x0C, 0x84, 0x03, 0x80, 0x02, 0x81, 0x06, 0x80, 0x07, + 0x81, 0x08, 0x89, 0x02, 0x88, 0x05, 0x80, 0x07, 0x84, 0x02, 0x86, 0x05, + 0x80, 0x0D, 0x81, 0x0B, 0x80, 0x08, 0x88, 0x01, 0x89, 0x05, 0x80, 0x04, +}; + +typedef struct { + const uint8_t *stream; + uint16_t length; + uint8_t levelIndex; // 0-based +} StDemoEntryT; + +static const StDemoEntryT kDemoRotation[4] = { + { kDemoStreamH, 296u, 7u }, + { kDemoStreamW, 334u, 22u }, + { kDemoStreamT, 374u, 19u }, + { kDemoStreamX, 564u, 23u }, +}; + +#endif diff --git a/stEngine.c b/stEngine.c index 2191f7e..2c766c2 100644 --- a/stEngine.c +++ b/stEngine.c @@ -50,15 +50,15 @@ #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 +// Death-animation duration, in GAME TICKS (the death countdown runs +// inside stEngineTick, now stepped at the fixed 30 Hz cadence). Matches +// the C64 sequence at $6A72/$6B24/$6B4C: phase-1 integrates Y velocity +// until the cab falls to the floor (row >= $DA), phase-2 walks cels +// $CC..$D1 at a slowing rate, phase-3 holds ~70 video frames (~35 game +// ticks). 60 game ticks (~2 s) comfortably covers fall + impact walk + +// hold. The cab keeps falling under gravity during the countdown so the +// visual matches the original "watch it drop". +#define ST_CRASH_ANIM_FRAMES 60u // 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 @@ -164,6 +164,11 @@ static void respawnTaxi(StGameT *game) { t->landed = false; t->onPad = 0xFFu; t->thrusting = false; + // $6892 forces #$C0 on every respawn: gear up, facing right. + t->gearDown = false; + t->fireHeld = false; + t->facing = ST_DIR_RIGHT; + t->crashImpacted = 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++) { @@ -226,11 +231,21 @@ void stEngineTick(StGameT *game) { if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; } t->y += t->vy; if (t->y > maxY) { + // Floor hit: the C64's phase-1 -> phase-2 transition (row + // $DA, $6ACC) -- the debris stops falling and the impact + // cel walk plays out where it landed. t->y = maxY; t->vy = 0; + t->crashImpacted = true; } t->crashTicks--; if (t->crashTicks == 0u) { + if (game->state == ST_STATE_DEMO) { + // $6B57-$6B5E: a demo crash skips the life bookkeeping + // entirely; the main loop returns to the title. + game->demoEnded = true; + return; + } if (game->lives > 0u) { game->lives--; } @@ -256,19 +271,6 @@ void stEngineTick(StGameT *game) { 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. @@ -314,8 +316,12 @@ void stEngineTick(StGameT *game) { // crashes into it -- this is the "land gently or die" // mechanic. In the port's 8-bit sub-pixel scale that high- // byte-zero test is vy < ST_SUBPIXEL (256). + // $6462 additionally requires the GEAR DOWN: with the gear + // up the pad detector aborts and the pad surface is just + // lethal background -- fast descent OR gear-up = crash. bool slowDescent = (t->vy < ST_SUBPIXEL); - if (slowDescent && onLandingPad(L, centerX, feetY, &landingPad)) { + if (slowDescent && t->gearDown && + 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; @@ -335,6 +341,7 @@ void stEngineTick(StGameT *game) { t->vy = ST_CRASH_FALL_VY; t->thrusting = false; t->crashTicks = ST_CRASH_ANIM_FRAMES; + t->crashImpacted = false; } } else { t->landed = false; @@ -382,10 +389,20 @@ bool stEngineInTransporter(const StGameT *game) { // Transporter shrink-warp ($5C91 takeoff + $5CB9 pad2pad). The C64 walks // the cab sprite through shrink cels $E2..$E9 (8 cels / 7 steps) while // nudging it +1px right / -1px up per step, with a cadence that slows as -// it recedes: 4 frames/step for the first steps, 16 frames/step for the -// last. Step boundaries (cumulative frames): 4,8,12,16,32,48,64. Total 64 -// frames (~1.1s). The port has no shrink cels, so the renderer scales a -// block by stEngineWarpStep instead. +// it recedes. $5CB9 INCs $5A66 and gates the step on (counter & mask): +// mask $03 (every 4) for the first steps, then $0F (every 16) from $E5. +// +// UNIT PROOF (from raw.bin, so this is not re-litigated): the animation +// runs in the loop at $5B85 -- JSR $5BBB (the animDispatcher, sole caller, +// which advances $5A66 via $5CB9) is called ONCE per iteration, while the +// single-frame raster wait $404B (LDA $D019 / AND #$01 / BEQ on line 252) +// runs TWICE per iteration ($5BAC + $5BB2), exactly like the gameplay +// loop's $5F94/$5FA3. So $5A66 advances once per 2 video frames = one +// GAME TICK. The boundaries 4,8,12,16,32,48,64 and the total 64 are +// therefore GAME TICKS: 64 ticks = 128 video frames = ~2.13 s NTSC. The +// values stay in their native C64 units (stEngineWarpTick runs once per +// game tick), unlike the port-invented crashTicks. The port has no shrink +// cels, so the renderer scales a block by stEngineWarpStep instead. #define ST_WARP_TOTAL_FRAMES 64u static const uint8_t kWarpStepFrame[8] = { 0u, 4u, 8u, 12u, 16u, 32u, 48u, 64u }; @@ -410,7 +427,10 @@ void stEngineStartWarp(StGameT *game) { game->taxi.vx = 0; game->taxi.vy = 0; game->taxi.thrusting = false; - stAudioSfxCrash(); // the C64 sets voice-3 noise ($81) on warp start + // $5CB0: the C64 gates SID voice-3 noise on ($D412 = $81) for the + // warp. Voice 3's freq word is $0617 (set at the $4092 RNG init, + // ~92 Hz shift rate) -- the darkest rumble, portable pitch 31. + jlAudioNoise(31u, 4u); } @@ -429,6 +449,7 @@ bool stEngineWarpTick(StGameT *game) { } if (t->warpFrame >= (uint8_t)ST_WARP_TOTAL_FRAMES) { t->warpFrame = 0u; + jlAudioNoise(0u, 15u); // warp done: rumble off return true; } return false; diff --git a/stHud.c b/stHud.c index 529e1bd..459a3b8 100644 --- a/stHud.c +++ b/stHud.c @@ -64,6 +64,10 @@ void stHudDraw(jlSurfaceT *stage, const StGameT *game) { if (!p->active) { continue; } + if (p->phase == ST_PASS_DROP_IN || p->phase == ST_PASS_WALK_TO_PAD || + p->phase == ST_PASS_DROP_OUT) { + break; // delivery in progress: no hail, no dest + } if (!p->onboard) { snprintf(buf, sizeof(buf), "HEY TAXI!"); } else if (p->destPad == ST_DEST_TRANSPORTER) { diff --git a/stLevel.c b/stLevel.c index 84a6666..1603ddf 100644 --- a/stLevel.c +++ b/stLevel.c @@ -11,6 +11,7 @@ #include #include +#include "joey/file.h" #include "spacetaxi.h" @@ -47,7 +48,8 @@ bool stLevelLoad(StLevelT *out, const char *path) { memset(out, 0, sizeof(*out)); - fp = fopen(path, "rb"); + // path is a name relative to DATA/ (e.g. "levels/title.dat"). + fp = jlDataOpen(path, "rb"); if (fp == NULL) { return false; } diff --git a/stPassenger.c b/stPassenger.c index fa94900..7aab2e7 100644 --- a/stPassenger.c +++ b/stPassenger.c @@ -27,11 +27,13 @@ #include "spacetaxi.h" -#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 +// The waiting passenger's in-place wave (and every beam/walk step) +// advances every 3 game ticks -- the C64's $715E == 3 reload. Since +// stPassengerTick is now called once per fixed game tick (~30 Hz), +// this is a plain tick counter, in lockstep with the physics. +#define ST_WAVE_STEP_TICKS 3u // Verified via emulator trace of $4354 (BCD-add) with the $43B9 blob: // a basic delivered fare adds '5' at the ones digit of the DDDD.DD HUD @@ -46,7 +48,7 @@ static uint32_t gRng; -static uint8_t gWalkTick; +static uint8_t gWaveTicks; // game ticks since the last wave/step advance // Frames until the next fare is forced to spawn (mirrors gDeathStage0Rng). static int16_t gSpawnCountdown; // Pad deliveries completed on the current screen. Destination pads are @@ -56,12 +58,22 @@ static int16_t gSpawnCountdown; static uint8_t gDeliveredThisScreen; +static bool arrivedAtTarget(const StPassengerT *p); static void deliverFare(StGameT *game, StPassengerT *p); static void pickDestination(StGameT *game, StPassengerT *p); static void placeOnPad(StPassengerT *p, const StLevelT *level, uint8_t padIdx); static uint8_t rngPad(uint8_t n); static void spawnPassenger(StGameT *game); -static void walkPassenger(StGameT *game, StPassengerT *p); +static void walkStride(StPassengerT *p); + + +// Walk arrival test. The C64 aligns the stride with AND #$FE and +// compares ((x ^ target) & ~1) == 0; the port's +/-2 window is the +// same idea, robust to an odd captured taxi X. +static bool arrivedAtTarget(const StPassengerT *p) { + int16_t d = (int16_t)(p->x - p->targetX); + return d >= -2 && d <= 2; +} static void deliverFare(StGameT *game, StPassengerT *p) { @@ -117,6 +129,18 @@ static uint8_t rngPad(uint8_t n) { } +// One +/-2 px walk stride ($6D66 rightward, $6D68 leftward), with +// the stride cel alternation on waitPhase's low bit. +static void walkStride(StPassengerT *p) { + if (p->targetX > p->x) { + p->x = (int16_t)(p->x + 2); + } else if (p->targetX < p->x) { + p->x = (int16_t)(p->x - 2); + } + p->waitPhase ^= 1u; +} + + static void spawnPassenger(StGameT *game) { StPassengerT *p = &game->passengers[0]; uint8_t padIdx; @@ -128,51 +152,17 @@ static void spawnPassenger(StGameT *game) { p->active = true; p->onboard = false; - p->walkPhase = 0u; - p->walkDir = 0u; + p->phase = ST_PASS_BEAM_IN; + p->waitPhase = 0u; p->destPad = 0u; placeOnPad(p, &game->level, padIdx); } -// Idle back-and-forth walk across the pad while waiting to be picked up. -// The C64 passenger AI wasn't fully traced; this gives each waiting fare -// some motion instead of standing still. -static void walkPassenger(StGameT *game, StPassengerT *p) { - const StPadT *pad; - int16_t padXMin; - int16_t padXMax; - - p->walkPhase = (uint8_t)((p->walkPhase + 1u) % ST_WALK_FRAMES); - if (p->currentPad >= game->level.padCount) { - return; - } - 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) { - return; // 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; - } - } -} - - void stPassengerReset(StGameT *game) { uint8_t i; - gWalkTick = 0u; + gWaveTicks = 0u; gDeliveredThisScreen = 0u; gSpawnCountdown = ST_SPAWN_CAP_FRAMES; // Deterministic per-screen seed: varies the pad sequence level to @@ -206,25 +196,112 @@ void stPassengerTick(StGameT *game) { return; } - if (!p->onboard) { - // Walk in place while waiting. - if (++gWalkTick >= ST_WALK_TICKS_PER_CEL) { - gWalkTick = 0u; - walkPassenger(game, p); + // One shared step clock (the C64's 3-game-tick cadence, $715E == 3) + // drives every phase: beams, wave, and walk strides. Called once + // per fixed game tick, so this is a plain tick count. + { + bool stepDue = (++gWaveTicks >= ST_WAVE_STEP_TICKS); + if (stepDue) { + gWaveTicks = 0u; } - // Board when the taxi has landed on their pad. - if (t->landed && t->onPad == p->currentPad) { - p->onboard = true; - pickDestination(game, p); - stAudioSfxPickup(); - } - } else if (p->destPad != ST_DEST_TRANSPORTER) { - // Deliver by landing on the destination pad. (UP-PLEASE fares are - // delivered on the transporter fly-up; see stPassengerTransporterExit.) - if (t->landed && - t->onPad < game->level.padCount && - t->onPad == p->destPad) { - deliverFare(game, p); + + switch (p->phase) { + case ST_PASS_BEAM_IN: + // Materialize at standX: cels $CB..$C7 (5 steps). + if (stepDue && ++p->waitPhase >= 5u) { + p->phase = ST_PASS_WAIT; + p->waitPhase = 0u; + } + break; + + case ST_PASS_WAIT: + // Stand still and wave through the $66D6 cycle. + if (stepDue) { + p->waitPhase = (uint8_t)((p->waitPhase + 1u) & 3u); + } + if (t->landed && t->onPad == p->currentPad) { + // $6811 captures the taxi X ONCE at landing; the + // walk aims there even if the cab lifts off. + p->targetX = (int16_t)(t->x >> ST_SUBPIXEL_SHIFT); + p->phase = ST_PASS_WALK_TO_CAB; + p->waitPhase = 0u; + } + break; + + case ST_PASS_WALK_TO_CAB: + if (stepDue) { + walkStride(p); + if (arrivedAtTarget(p)) { + if (t->landed && t->onPad == p->currentPad) { + p->phase = ST_PASS_BOARD_OUT; + p->waitPhase = 0u; + } else { + // Cab left early: walk home and wait again. + // (Not byte-traced -- the C64 cab cannot + // normally leave mid-walk; this just keeps + // the state machine sane if ours does.) + p->targetX = (int16_t)game->level.pads[p->currentPad].standX; + p->phase = ST_PASS_WALK_TO_PAD; + p->waitPhase = 0u; + } + } + } + break; + + case ST_PASS_BOARD_OUT: + // Shrink into the cab: cels $C7..$CB (5 steps). + if (stepDue && ++p->waitPhase >= 5u) { + p->onboard = true; + p->phase = ST_PASS_RIDING; + p->waitPhase = 0u; + pickDestination(game, p); + stAudioSfxPickup(); + } + break; + + case ST_PASS_RIDING: + // Deliver by landing on the destination pad. (UP-PLEASE + // fares deliver on the transporter fly-up instead; see + // stPassengerTransporterExit.) + if (p->destPad != ST_DEST_TRANSPORTER && + t->landed && + t->onPad < game->level.padCount && + t->onPad == p->destPad) { + p->onboard = false; + p->phase = ST_PASS_DROP_IN; + p->waitPhase = 0u; + p->x = (int16_t)(t->x >> ST_SUBPIXEL_SHIFT); + p->targetX = (int16_t)game->level.pads[p->destPad].standX; + p->currentPad = p->destPad; + p->y = (int16_t)((game->level.pads[p->destPad].tileY - + ST_PASSENGER_H_TILES) * ST_TILE_PIXELS); + } + break; + + case ST_PASS_DROP_IN: + // Materialize beside the cab: cels $CB..$C7. + if (stepDue && ++p->waitPhase >= 5u) { + p->phase = ST_PASS_WALK_TO_PAD; + p->waitPhase = 0u; + } + break; + + case ST_PASS_WALK_TO_PAD: + if (stepDue) { + walkStride(p); + if (arrivedAtTarget(p)) { + p->phase = ST_PASS_DROP_OUT; + p->waitPhase = 0u; + } + } + break; + + case ST_PASS_DROP_OUT: + // Shrink out at standX -- fare complete. + if (stepDue && ++p->waitPhase >= 5u) { + deliverFare(game, p); + } + break; } } } diff --git a/stRender.c b/stRender.c index 250f080..c15eea6 100644 --- a/stRender.c +++ b/stRender.c @@ -33,9 +33,10 @@ // 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_PATH_FMT "tiles/tbank%u.tbk" +#define ST_SPRITE_SHEET_PATH "sprites/sprites.spr" +#define ST_SPRITE_SHEET_SPC "sprites/sprites.spc" +#define ST_FONT_PATH "font.tbk" #define ST_TILE_BANK_MAX 256u @@ -52,29 +53,33 @@ // 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_SHEET_CELS 55 /* 11 cols x 5 rows: taxi, passenger, flame, warp, death */ #define ST_SPRITE_TAXI_FIRST 0 -#define ST_SPRITE_PASS_FIRST 9 -#define ST_SPRITE_FLAME_FIRST 18 +#define ST_SPRITE_PASS_FIRST 11 +#define ST_SPRITE_FLAME_FIRST 22 // 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. +// sprite asset (extractSprites.py, real C64 art from raw.bin) lays +// out sheet row 0 in the live sprite-pointer order: +// cel 0 = $C1 (right, gear DOWN) cel 1 = $C0 (right, gear up) +// cel 2 = $DC (left, gear up) cel 3 = $DD (left, gear DOWN) +// $619B selects the base pair by held direction (LEFT -> $DC pair, +// RIGHT -> $C0 pair, otherwise keep the last facing) and preserves +// bit 0 = the LANDING GEAR state (fire-toggled, $63DD). The in- +// flight body NEVER animates -- the cel is a static function of +// (facing, gearDown): +// cel = facingLeft ? (2 + gearDown) : (1 - gearDown) +// (The $6A95 EOR that an earlier port version modeled as a flicker +// is the crash-debris animation, $CC<->$CD during the fall.) #define ST_TAXI_W_PX 24 #define ST_TAXI_H_PX 24 #define ST_TAXI_CEL_COUNT 4 -// Transporter shrink-warp cels: the cab receding, in the free row-0 slots -// right after the 4 drive cels. The C64 walks 8 shrink cels ($E2..$E9); -// the port approximates with 5 sizes and maps the 8 warp steps onto them. -#define ST_SPRITE_WARP_FIRST (ST_SPRITE_TAXI_FIRST + ST_TAXI_CEL_COUNT) -#define ST_WARP_CEL_COUNT 5 +// Transporter shrink-warp cels (sheet row 3): the SEVEN drawn cels +// $E2..$E8 of the $5CB9 chain, identity-mapped to warp steps 0..6. +// $5CDA ends the warp when the pointer REACHES $E9 and hides the +// sprite ($718E = 0) -- $E9 is never displayed, so step 7 draws +// nothing at all. +#define ST_SPRITE_WARP_FIRST 33 +#define ST_WARP_CEL_COUNT 7 // Flame placeholder fallback (only used if real sprite-2 cel is // missing): a small bright rectangle below the cab while thrusting. @@ -88,10 +93,16 @@ // 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. +// other GAME TICK (2 video frames, so a 12.5Hz PAL strobe that fuses +// on a CRT). The port reproduces the LOOK with a wall-clock virtual +// strobe (flameStrobeOn): at high frame rates it yields the authentic +// shimmer; a rendered frame long enough to span a whole strobe phase +// (low-fps IIgs) shows the flame continuously instead of a 7.5Hz +// blink that reads as "no flame". #define ST_FLAME_CEL_W_PX 24 #define ST_FLAME_CEL_H_PX 24 #define ST_FLAME_CEL_COUNT 8 +#define ST_FLAME_STROBE_MS 40u /* one $716C phase = 1 PAL game tick */ // Passenger: 24x24 (C64 hardware sprite size is 24x21; we pad 3 rows // transparent at the bottom in the asset extraction to keep a square @@ -101,11 +112,28 @@ // $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_COUNT 11 +#define ST_PASSENGER_CEL_WALK0 0 /* $C4 walk LEFT a */ +#define ST_PASSENGER_CEL_WALK1 1 /* $C5 walk LEFT b */ #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_PASSENGER_CEL_WALKR0 9 /* $C2 walk RIGHT a */ +#define ST_PASSENGER_CEL_WALKR1 10 /* $C3 walk RIGHT b */ + +// Crash-death cels (sheet row 4): $CC/$CD debris alternation during +// the fall, then the $CE..$D1 ground-impact walk, then hidden. +#define ST_DEATH_CEL_COUNT 6 +#define ST_SPRITE_DEATH_FIRST 44 + +// Startup progress bar: 2 load steps (font, sprite bank) + the 36 +// per-cel compiles (4 taxi + 11 passenger + 8 flame + 7 warp + +// 6 death). +#define ST_LOAD_STEPS_TOTAL 38 +#define ST_LOAD_BAR_X 60 +#define ST_LOAD_BAR_Y 110 +#define ST_LOAD_BAR_H 6 +#define ST_LOAD_BAR_COLOR 1u /* white fill */ +#define ST_LOAD_BAR_FRAME_COLOR 6u /* blue frame, cab color */ // Library-wide worst case (16-px-group window on planar ports). #define ST_SPRITE_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(ST_TAXI_W_PX / 8, ST_TAXI_H_PX / 8) @@ -118,6 +146,7 @@ typedef struct { bool bankLoaded; jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT]; jlSpriteT *warpCels[ST_WARP_CEL_COUNT]; + jlSpriteT *deathCels[ST_DEATH_CEL_COUNT]; jlSpriteT *flameCels[ST_FLAME_CEL_COUNT]; jlSpriteBackupT flameBackup; uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2))); @@ -132,6 +161,10 @@ typedef struct { uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2))); bool taxiHasBackup; bool passengerHasBackup[ST_MAX_PASSENGERS]; + uint32_t flamePrevMs; // flameStrobeOn frame-span tracker + uint32_t deathMs; // crash-anim step anchor + uint8_t deathStep; // impact-walk position (0..4) + bool deathActive; // crash anim state latched // 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 @@ -145,8 +178,11 @@ static StRenderStateT gRender; static bool loadTileBank(uint8_t bankId); -static bool loadSpriteSheet(void); +static bool loadSpriteSheet(jlSurfaceT *stage); static bool loadFontSheet(jlSurfaceT *stage); +static void loadProgress(jlSurfaceT *stage, uint8_t step); +static bool flameStrobeOn(void); +static int16_t passCelForPtr(uint8_t ptr); static void buildAsciiMap(void); static void destroySprites(void); @@ -172,26 +208,11 @@ static void destroySprites(void); // 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, -}; +// (The fabricated title "demo physics" stage and its truncated copy +// of demo stream 01 were deleted 2026-07-21: the real C64 never flies +// the cab around the title after takeoff -- it enters the ATTRACT +// DEMO, now a proper game state (ST_STATE_DEMO) driven by the full +// recorded input streams in stDemoStreams.h.) // C64 reference values straight from the asm at $4A03 dispatch + // titleSpriteSetup ($4525) + sprite-init tables ($4994/$49AC). @@ -199,7 +220,7 @@ static const uint8_t kTitleDemoScript[256] = { #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_STAGE_DONE 6u // intro over; attract demo takes over #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. @@ -220,7 +241,16 @@ static const uint8_t kTitleDemoScript[256] = { // $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 }; +// The $66D6 wave/sparkle cycle {$C6,$C7,$D9,$C7}: the title sparkle +// AND the waiting passenger's in-place wave both step through it (the +// gameplay passenger never moves -- he stands at standX and waves). +static const uint8_t kPassengerWaveCels[4] = { 0xC6, 0xC7, 0xD9, 0xC7 }; + +// Crash impact walk cadence ($6B2A: reload starts at 2 ticks and +// INCs each step -- 2,3,4,5 ticks for cels $CE..$D1 at the NTSC +// 33 ms game tick), then the sprite hides. +static const uint16_t kDeathStepMs[4] = { 66u, 100u, 133u, 166u }; +#define ST_DEATH_TUMBLE_MS 66u /* $CC/$CD swap every 2 game ticks */ // Map direction-mask (CIA1 PortA bits after EOR #$FF: bit0=UP bit1=DOWN // bit2=LEFT bit3=RIGHT) to a flameCels[] index, mirroring the C64 @@ -267,22 +297,16 @@ typedef struct { 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; // Logo flip-book + color cycle. The "SPACE TAXI" logo is 103 cells // of char $84; the C64 ($4827) copies one of the flip frames // $85..$88 over char $84's bitmap every 4th frame, walking the // ping-pong table $48A7 = {1,2,3,4,3,2} so each dot rotates end over // end, and advances the 8-color cycle $489C when the index hits 4. - uint8_t logoFlipDiv; // mirrors $48A6 (mod-4 frame divider) + uint8_t logoFlipDiv; // mirrors $48A6 (mod-4 tick divider) uint8_t logoFlipIdx; // mirrors $48A5 (0..5 ping-pong index) uint8_t logoColorIdx; // mirrors $48A4 (0..7 color cycle index) + bool logoNeedsPaint; // flip/color changed (or tilemap repainted) } StTitleStateT; // The single animated logo character. Its bitmap is flip-booked through @@ -355,14 +379,45 @@ void stRenderInit(jlSurfaceT *stage) { 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); - } + // Startup feedback: the IIgs spends several seconds streaming the + // banks and compiling sprite cels to their asm routines, all of + // which used to happen against a black screen. Show a growing bar + // immediately; once the font is in, add the LOADING caption. The + // font loads FIRST because text needs its glyph surface. + jlSurfaceClear(stage, 0u); + loadProgress(stage, 0u); if (!loadFontSheet(stage)) { jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH); } buildAsciiMap(); + loadProgress(stage, 1u); + + // Tile bank is loaded on demand in stRenderLevel (per-level). + if (!loadSpriteSheet(stage)) { + jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH); + } + titleReset(); +} + + +bool stRenderTitleIntroDone(void) { + return gTitle.stage == ST_TITLE_STAGE_DONE; +} + + +// Advance the title intro by ONE game tick. The main loop calls this +// on the fixed 30 Hz accumulator (not once per rendered frame) so the +// sparkle / walk / takeoff sequence and the logo flip run at the C64's +// rate on every port -- identical pre-game timing everywhere. +void stRenderTitleAdvance(void) { + titleTick(); +} + + +// Restart the title intro (sparkle -> walk -> takeoff). Called on +// every return to the title so the attract cycle replays -- the C64 +// re-enters the intro the same way after a demo exits. +void stRenderTitleReset(void) { titleReset(); } @@ -478,14 +533,12 @@ static void titleReset(void) { 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; - // Force the first frame to be an animation step (3 -> 0 on first ++) - // so the logo paints its first flip frame + color immediately. + // Force the first tick to be an animation step (3 -> 0 on first ++) + // AND force a logo paint on the first render frame so the flip + // frame + color appear immediately (the tilemap was just repainted + // by stRenderLevelChanged, erasing any prior logo cells). gTitle.logoFlipDiv = 3u; + gTitle.logoNeedsPaint = true; } @@ -498,7 +551,32 @@ static void titleReset(void) { // color cycle $489C = {red,orange,yellow,green,blue,lt-blue,cyan,purple} // (C64 codes, which are this port's palette indices). We reproduce it by // re-pasting every $84 cell with the current flip-frame tile + color. -static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) { +// Flip/color advance ($4827 divider + $484D/$4853 index+color). Runs +// once per GAME TICK (called from titleTick), so the flip-book steps +// every 4 game ticks -- the C64's $48A6 mod-4 gate at the 30 Hz main- +// loop rate, frame-rate-independent. Sets logoNeedsPaint when it steps; +// the actual re-paste is deferred to titlePaintLogo in the render pass. +static void titleAdvanceLogo(void) { + gTitle.logoFlipDiv = (uint8_t)((gTitle.logoFlipDiv + 1u) & 3u); + if (gTitle.logoFlipDiv != 0u) { + return; + } + gTitle.logoFlipIdx++; + if (gTitle.logoFlipIdx == 6u) { + gTitle.logoFlipIdx = 0u; + } + if (gTitle.logoFlipIdx == 4u) { + gTitle.logoColorIdx = (uint8_t)((gTitle.logoColorIdx + 1u) & 7u); + } + gTitle.logoNeedsPaint = true; +} + + +// Re-paste every $84 logo cell with the current flip-frame tile + color +// ($489C palette codes = the port's indices). Render-pass only, and only +// when the flip advanced or the tilemap was just repainted -- between +// steps the logo cells sit static on the tilemap. +static void titlePaintLogo(jlSurfaceT *stage, const StLevelT *level) { static const uint8_t kFlip[6] = { 1u, 2u, 3u, 4u, 3u, 2u }; // $48A7 static const uint8_t kColor[8] = { 0x02u, 0x08u, 0x07u, 0x05u, 0x06u, 0x0Eu, 0x03u, 0x04u }; // $489C @@ -509,14 +587,11 @@ static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) { uint8_t by; size_t i; - // $4827: only step the flip-book every 4th frame. - gTitle.logoFlipDiv = (uint8_t)((gTitle.logoFlipDiv + 1u) & 3u); - if (gTitle.logoFlipDiv != 0u) { + if (!gTitle.logoNeedsPaint) { return; } + gTitle.logoNeedsPaint = false; - // Paint the CURRENT flip frame + color (C64 displays table[$48A5] - // this frame, then advances), then repaint every $84 logo cell. ch = (uint8_t)(ST_LOGO_CHAR + kFlip[gTitle.logoFlipIdx]); fg = kColor[gTitle.logoColorIdx]; tile = gRender.tileValid[ch] ? &gRender.tiles[ch] : NULL; @@ -530,16 +605,6 @@ static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) { } } } - - // $484D INC $48A5 + $4853 gate: wrap the ping-pong index at 6, and - // advance the color cycle when it reaches 4. - gTitle.logoFlipIdx++; - if (gTitle.logoFlipIdx == 6u) { - gTitle.logoFlipIdx = 0u; - } - if (gTitle.logoFlipIdx == 4u) { - gTitle.logoColorIdx = (uint8_t)((gTitle.logoColorIdx + 1u) & 7u); - } } @@ -577,6 +642,11 @@ static void titleStepPassenger(void) { // $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) { + // Logo flip-book + color cycle advance once per game tick (its own + // mod-4 divider gates the actual step), independent of the intro + // stage machine below. + titleAdvanceLogo(); + switch (gTitle.stage) { case ST_TITLE_STAGE_SPARKLE: // $4A17: JSR $66B7 / DEC $473F / BEQ -> INC $7163. @@ -600,7 +670,7 @@ static void titleTick(void) { if (gTitle.decayTimer == 0u) { gTitle.decayTimer = gTitle.decayReload; gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u); - gTitle.passengerPtr = kTitleSparkleCels[gTitle.sparkleIdx]; + gTitle.passengerPtr = kPassengerWaveCels[gTitle.sparkleIdx]; } if (gTitle.introFrameCount > 0u) { gTitle.introFrameCount--; @@ -652,90 +722,23 @@ static void titleTick(void) { 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 + // $4A64 INCs gDeathStage out of the intro: on the C64 the + // machine now enters the rolling attract demo. The port + // signals spacetaxi.c via stRenderTitleIntroDone(). + gTitle.stage = ST_TITLE_STAGE_DONE; } break; - case ST_TITLE_STAGE_DEMO: + case ST_TITLE_STAGE_DONE: 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; - } - } + // Intro finished -- the title art sits static (logo cycle + // continues); spacetaxi.c switches to ST_STATE_DEMO. break; } - } } @@ -778,10 +781,12 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { // (e.g., transitioned from gameplay back to title) -- otherwise // it's a fast no-op. stRenderLevel(stage, &game->level); - // Flip-book + color-cycle the "SPACE TAXI" logo (char $84 cells) - // over the freshly-painted tilemap, BEFORE the demo sprites - // save-under and draw, so the sprites overlay the live logo. - titleAnimateLogo(stage, &game->level); + // Paint the "SPACE TAXI" logo (char $84 cells) at its current + // flip frame + color over the freshly-painted tilemap, BEFORE + // the sprites save-under and draw so they overlay it. The flip + // ADVANCE happens on the game-tick clock (titleAdvanceLogo); + // this only re-pastes when it changed or the tilemap repainted. + titlePaintLogo(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 @@ -790,15 +795,15 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { // 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(); + // The intro state machine (sparkle -> passenger walk -> taxi + // takeoff) is advanced on the fixed game-tick clock in the main + // loop (stRenderTitleAdvance), not here -- this pass only DRAWS + // whichever sprites the current intro state has visible. // 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) && + if (gTitle.flameVisible && flameStrobeOn() && gTitle.flameDirMask < 16u) { int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask]; if (cel >= 0 && gRender.flameCels[cel] != NULL) { @@ -812,15 +817,7 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { } } 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; - } + int16_t cel = passCelForPtr(gTitle.passengerPtr); if (cel < 0) { cel = 0; } @@ -931,12 +928,19 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { } // 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. + // shows through where the taxi/passenger/flame used to be. Then + // we'll save+draw at the new positions below. LIFO order: the cab + // was saved AFTER the flame (its backup holds flame pixels), so + // the cab restores first and the flame restore then cleans the + // re-deposited flame pixels. if (gRender.taxiHasBackup) { jlSpriteRestoreUnder(stage, &gRender.taxiBackup); gRender.taxiHasBackup = false; } + if (gRender.flameHasBackup) { + jlSpriteRestoreUnder(stage, &gRender.flameBackup); + gRender.flameHasBackup = false; + } for (i = 0u; i < ST_MAX_PASSENGERS; i++) { if (gRender.passengerHasBackup[i]) { jlSpriteRestoreUnder(stage, &gRender.passengerBackup[i]); @@ -952,43 +956,82 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *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; - } + // $619B cel select: facing picks the $C0/$C1 (right) or $DC/$DD + // (left) pair; bit 0 is the LANDING GEAR state. The body is a + // STATIC cel -- no flicker of any kind. Sheet order + // [$C1,$C0,$DC,$DD]: + // right: ptr = $C0|gear -> cel 1 - gear + // left: ptr = $DC|gear -> cel 2 + gear + // (The crash anim uses the dedicated death cels below.) + if (game->taxi.facing == ST_DIR_LEFT) { + taxiCel = (uint8_t)(game->taxi.gearDown ? 3u : 2u); } else { - taxiCel = 0u; + taxiCel = (uint8_t)(game->taxi.gearDown ? 0u : 1u); } if (gRender.taxiCels[taxiCel] == NULL) { taxiCel = 0u; } - if (game->taxi.warpFrame > 0u) { - // Transporter shrink-warp: draw the cab-receding cel for this warp - // step (the C64 walks shrink cels $E2..$E9; the port has 5 sizes, - // one selected per step). Uses the taxi's save-under so it undraws - // cleanly next frame -- no tilemap repaint needed. - uint8_t step = stEngineWarpStep(game); /* 0..7 */ - uint8_t wcel = (uint8_t)((uint16_t)step * ST_WARP_CEL_COUNT / 8u); - if (wcel >= ST_WARP_CEL_COUNT) { - wcel = (uint8_t)(ST_WARP_CEL_COUNT - 1u); + // Engine flame: C64 sprite 2 ($6D6A) at (cab_x - 2, cab_y), + // cel indexed by the $716A direction mask through the $6DB0 + // table, drawn on alternate frames only (the parity flicker), + // hidden when no input is held. Drawn BEFORE the cab so the cab + // (sprite 0) keeps VIC priority on top. + if (game->taxi.warpFrame == 0u && game->taxi.thrusting && + !game->taxi.landed && flameStrobeOn() && + game->taxi.dirMask < 16u) { + int8_t fcel = kFlameCelByDirMask[game->taxi.dirMask]; + if (fcel >= 0 && gRender.flameCels[fcel] != NULL) { + jlSpriteSaveAndDraw(stage, gRender.flameCels[fcel], + (int16_t)(px - 2), py, + &gRender.flameBackup); + gRender.flameHasBackup = true; } - if (gRender.warpCels[wcel] != NULL) { - jlSpriteSaveAndDraw(stage, gRender.warpCels[wcel], px, py, + } + if (game->taxi.crashTicks > 0u) { + // Crash death: phase 1 tumbles the $CC/$CD debris pair while + // falling; the floor hit ($6ACC row $DA) starts the $CE..$D1 + // impact walk at the slowing $6B2A cadence, then the wreck + // hides until respawn. + uint32_t nowMs = jlMillisElapsed(); + int16_t dcel = -1; + if (!gRender.deathActive) { + gRender.deathActive = true; + gRender.deathMs = nowMs; + gRender.deathStep = 0u; + } + if (!game->taxi.crashImpacted) { + dcel = (int16_t)((nowMs / ST_DEATH_TUMBLE_MS) & 1u); + gRender.deathMs = nowMs; // re-anchor for the walk + gRender.deathStep = 0u; + } else { + if (gRender.deathStep < 4u && + nowMs - gRender.deathMs >= kDeathStepMs[gRender.deathStep]) { + gRender.deathMs = nowMs; + gRender.deathStep++; + } + if (gRender.deathStep < 4u) { + dcel = (int16_t)(2u + gRender.deathStep); + } + } + if (dcel >= 0 && gRender.deathCels[dcel] != NULL) { + jlSpriteSaveAndDraw(stage, gRender.deathCels[dcel], px, py, + &gRender.taxiBackup); + gRender.taxiHasBackup = true; + } + } else if (game->taxi.warpFrame > 0u) { + // Transporter shrink-warp: identity map onto the SEVEN drawn + // chain cels $E2..$E8; step 7 (pointer reaching $E9) hides + // the sprite entirely, exactly like $5CDA/$718E. + uint8_t step = stEngineWarpStep(game); /* 0..7 */ + if (step < ST_WARP_CEL_COUNT && gRender.warpCels[step] != NULL) { + jlSpriteSaveAndDraw(stage, gRender.warpCels[step], px, py, &gRender.taxiBackup); gRender.taxiHasBackup = true; } } else if (gRender.taxiCels[taxiCel] != NULL) { // Save-under + draw. Backup is replayed at the start of next // frame above to undraw cleanly. + gRender.deathActive = false; jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py, &gRender.taxiBackup); gRender.taxiHasBackup = true; @@ -1000,12 +1043,6 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { 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), @@ -1020,10 +1057,38 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { 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 + int16_t pcel; + if (!p->active || p->phase == ST_PASS_RIDING) { + continue; // riding passengers are invisible in the cab } - cel = gRender.passengerCels[p->walkPhase % ST_PASSENGER_CEL_COUNT]; + // Cel by lifecycle phase (see StPassPhaseE): beams shrink or + // grow through $C7..$CB, the wait wave cycles $66D6, walks + // alternate the direction's stride pair. + switch (p->phase) { + case ST_PASS_BEAM_IN: + case ST_PASS_DROP_IN: + pcel = (int16_t)(7 - (p->waitPhase > 4u ? 4u : p->waitPhase)); + break; + case ST_PASS_BOARD_OUT: + case ST_PASS_DROP_OUT: + pcel = (int16_t)(3 + (p->waitPhase > 4u ? 4u : p->waitPhase)); + break; + case ST_PASS_WALK_TO_CAB: + case ST_PASS_WALK_TO_PAD: + if (p->targetX < p->x) { + pcel = (int16_t)((p->waitPhase & 1u) ? ST_PASSENGER_CEL_WALK1 + : ST_PASSENGER_CEL_WALK0); + } else { + pcel = (int16_t)((p->waitPhase & 1u) ? ST_PASSENGER_CEL_WALKR1 + : ST_PASSENGER_CEL_WALKR0); + } + break; + case ST_PASS_WAIT: + default: + pcel = passCelForPtr(kPassengerWaveCels[p->waitPhase & 3u]); + break; + } + cel = gRender.passengerCels[pcel]; if (cel == NULL) { jlFillRect(stage, p->x, p->y, ST_PASSENGER_W_PX, ST_PASSENGER_H_PX, 8u); @@ -1035,6 +1100,13 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) { gRender.passengerHasBackup[i] = true; } + if (game->state == ST_STATE_DEMO) { + // Attract banners ($45A7-$4614 text): drawn every frame over + // the live demo, before the present. + stRenderDrawText(stage, 7u, 3u, "DEMO, USE JOYSTICK TO EXIT"); + stRenderDrawText(stage, 3u, 24u, "THIS IS 1 OF 25 DIFFERENT SCREENS!"); + } + if (game->state == ST_STATE_LEVEL_DONE) { int16_t midY = (int16_t)(10 * ST_TILE_PIXELS); jlFillRect(stage, 0, midY, SURFACE_WIDTH, @@ -1082,36 +1154,98 @@ static bool loadTileBank(uint8_t bankId) { } -static bool loadSpriteSheet(void) { +static bool loadSpriteSheet(jlSurfaceT *stage) { jlSpriteT *cels[ST_SPRITE_SHEET_CELS]; uint16_t count; uint16_t i; + uint8_t step; + bool precompiled; - // .spr blob carries all 27 cels (9 cols x 3 rows of 3x3-tile + // .spr blob carries all 36 cels (9 cols x 4 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 + // passenger, row 2 flame, row 3 warp. The padding slots between + // named sprites are loaded but never referenced; they leak a few + // 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); + // Prefer the pre-compiled bank: its cels arrive already compiled (native + // routines dropped straight into the codegen arena), so the jlSpriteCompile + // loop below is a no-op fast path. Fall back to the portable .spr + runtime + // JIT if no .spc is staged for this build. + count = jlSpriteBankLoadPrecompiled(ST_SPRITE_SHEET_SPC, cels, ST_SPRITE_SHEET_CELS, NULL); + precompiled = (count != 0u); + if (count == 0u) { + 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); + jlLogF("stRender: sprite sheet loaded (%u cels, %s)", (unsigned)count, + precompiled ? "PRECOMPILED" : "jit"); + loadProgress(stage, 2u); - for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) { - gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i]; + // Compile every drawn cel to its per-shift asm routine. Without + // this, jlSprite{Draw,SaveUnder,SaveAndDraw} fall to the per-pixel + // interpreter (sp->slot stays NULL) -- catastrophic on the IIgs + // 65816: the 3 title sprites alone cost ~200 ms/frame interpreted + // (~3.7 fps). Compiling routes them to the fast path (UBER's + // benchmarks prove codegen works on every port). jlSpriteCompile is + // a no-op fallback if the arena is exhausted (cel stays interpreted). + { + uint16_t compiled = 0u; + + // Assign all cels first, then compile title-critical ones + // (taxi, passenger, flame) before the gameplay-only warp cels so + // a tight arena still fast-paths the title. + for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) { + gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i]; + } + for (i = 0u; i < ST_WARP_CEL_COUNT; i++) { + gRender.warpCels[i] = cels[ST_SPRITE_WARP_FIRST + i]; + } + for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) { + gRender.deathCels[i] = cels[ST_SPRITE_DEATH_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]; + } + step = 3u; + for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) { + compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.taxiCels[i]) ? 1u : 0u)); + loadProgress(stage, step++); + } + for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) { + compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.passengerCels[i]) ? 1u : 0u)); + loadProgress(stage, step++); + } + for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) { + compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.flameCels[i]) ? 1u : 0u)); + loadProgress(stage, step++); + } + for (i = 0u; i < ST_WARP_CEL_COUNT; i++) { + compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.warpCels[i]) ? 1u : 0u)); + loadProgress(stage, step++); + } + for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) { + compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.deathCels[i]) ? 1u : 0u)); + loadProgress(stage, step++); + } + jlLogF("stRender: sprite cels compiled: %u/%u", (unsigned)compiled, + (unsigned)(ST_TAXI_CEL_COUNT + ST_PASSENGER_CEL_COUNT + ST_FLAME_CEL_COUNT + ST_WARP_CEL_COUNT + ST_DEATH_CEL_COUNT)); } - for (i = 0u; i < ST_WARP_CEL_COUNT; i++) { - gRender.warpCels[i] = cels[ST_SPRITE_WARP_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]; + + // First-run cache: having just JIT-compiled the bank, write it out as a + // .spc so the next launch loads the compiled routines and skips the JIT + // (ship the small .spr, cache the big per-platform compiled bank). Best- + // effort -- a read-only/write-protected disk just means we JIT again. + if (!precompiled) { + if (jlSpriteBankSavePrecompiled(ST_SPRITE_SHEET_SPC, cels, count, NULL)) { + jlLogF("stRender: cached compiled bank -> %s", ST_SPRITE_SHEET_SPC); + } } return true; } @@ -1125,6 +1259,12 @@ static void destroySprites(void) { gRender.taxiCels[i] = NULL; } } + for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) { + if (gRender.deathCels[i] != NULL) { + jlSpriteDestroy(gRender.deathCels[i]); + gRender.deathCels[i] = NULL; + } + } for (i = 0u; i < ST_WARP_CEL_COUNT; i++) { if (gRender.warpCels[i] != NULL) { jlSpriteDestroy(gRender.warpCels[i]); @@ -1172,6 +1312,70 @@ static bool loadFontSheet(jlSurfaceT *stage) { } +// The C64 flame strobe ($716C: one game tick on, one off) mapped to +// wall-clock so every port shows the intended LOOK: at >=50fps this +// is the authentic 12.5Hz shimmer; when a rendered frame spans a +// whole strobe phase (low fps) the flame draws every frame -- the +// CRT-fusion appearance instead of an unreadable slow blink. +static bool flameStrobeOn(void) { + uint32_t nowMs = jlMillisElapsed(); + bool phase = ((nowMs / ST_FLAME_STROBE_MS) & 1u) != 0u; + bool spans = (nowMs - gRender.flamePrevMs) >= ST_FLAME_STROBE_MS; + + gRender.flamePrevMs = nowMs; + return phase || spans; +} + + +// Map a live C64 sprite-1 pointer value onto the sheet's passenger +// row (extractSprites.py pass_ptrs order): +// $C4..$CB -> cels 0..7 (walk cels, boarding shrink) +// $D9 -> cel 8 (the wave/sparkle cel) +// Single source of truth for the title path and the gameplay wave. +static int16_t passCelForPtr(uint8_t ptr) { + if (ptr == 0xD9u) { + return ST_PASSENGER_CEL_SPARKLE; + } + if (ptr == 0xC2u) { + return ST_PASSENGER_CEL_WALKR0; + } + if (ptr == 0xC3u) { + return ST_PASSENGER_CEL_WALKR1; + } + return (int16_t)ptr - 0xC4; +} + + +// Startup progress: outline drawn on step 0 so the screen shows life +// the moment stRenderInit runs, bar grows per completed step, LOADING +// caption appears once the font glyphs exist. Cheap enough to call +// per sprite compile even on the IIgs (present base ~3 ms). +static void loadProgress(jlSurfaceT *stage, uint8_t step) { + int16_t w; + + if (step > (uint8_t)ST_LOAD_STEPS_TOTAL) { + step = (uint8_t)ST_LOAD_STEPS_TOTAL; + } + if (step == 0u) { + jlFillRect(stage, ST_LOAD_BAR_X - 2, ST_LOAD_BAR_Y - 2, + (uint16_t)(SURFACE_WIDTH - 2 * ST_LOAD_BAR_X + 4), + ST_LOAD_BAR_H + 4, ST_LOAD_BAR_FRAME_COLOR); + jlFillRect(stage, ST_LOAD_BAR_X - 1, ST_LOAD_BAR_Y - 1, + (uint16_t)(SURFACE_WIDTH - 2 * ST_LOAD_BAR_X + 2), + ST_LOAD_BAR_H + 2, 0u); + } + w = (int16_t)(((int32_t)step * (SURFACE_WIDTH - 2 * ST_LOAD_BAR_X)) / ST_LOAD_STEPS_TOTAL); + if (w > 0) { + jlFillRect(stage, ST_LOAD_BAR_X, ST_LOAD_BAR_Y, (uint16_t)w, + ST_LOAD_BAR_H, ST_LOAD_BAR_COLOR); + } + if (gRender.fontReady) { + stRenderDrawText(stage, 16u, 11u, "LOADING"); + } + jlStagePresent(); +} + + // 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),