Massive optimizations. New music player. Serial support.

This commit is contained in:
Scott Duensing 2026-07-24 22:36:21 -05:00
parent 1442bf79a2
commit d498a9f0b3
22 changed files with 1454 additions and 405 deletions

View file

@ -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 sets their ptr to `$DA` ($3680), color to 7, then triggers song 8
via `$CB02`. This is the **idle title state**. via `$CB02`. This is the **idle title state**.
`$4955` is the reverse: gate-off voice 1+2, noise off, load song `$4955` is the reverse: gate-off voice 1+2, noise off, then load a
from `$6F75,X` (X = $716B current music index), set $5E9D=0, disk file selected by `$6F75,X` (X = `$716B`) via the `$9936`
JMP $5EC4. **Exit-to-title cleanup**. 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 ## Lives + death state
@ -955,7 +966,7 @@ check_note:
AND #$7F ; mask high bit (effect flag) AND #$7F ; mask high bit (effect flag)
CMP #$61 ; note count = 97 CMP #$61 ; note count = 97
BCC normal_note BCC normal_note
JMP $CD56 ; effect command (out of scope for now) JMP $CD56 ; effect command (traced -- see "Effect commands" below)
normal_note: normal_note:
CLC; ADC $CB86,X ; transpose 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 $CE90,Y -> $D401,X ; voice freq HI
LDA $CBBF,X -> $D405,X ; attack/decay LDA $CBBF,X -> $D405,X ; attack/decay
LDA $CBC0,X -> $D406,X ; sustain/release LDA $CBC0,X -> $D406,X ; sustain/release
LDA $CBC1,X -> $D403,X ; pulse-width lo LDA $CBC1,X -> $D403,X ; pulse-width HI ($D403 is the PW hi register)
LDA $CBC2,X -> $D402,X ; pulse-width hi LDA $CBC2,X -> $D402,X ; pulse-width LO
LDA $CBC4,X ; filter flag LDA $CBC4,X ; filter flag
PHP PHP
LDA $D417; EOR #$FF; ORA $CBAB,X; EOR #$FF LDA $D417; EOR #$FF; ORA $CBAB,X; EOR #$FF
@ -985,13 +996,85 @@ A=0; STA $CB83,X ; active flag = 0
BEQ continue BEQ continue
``` ```
Frequency tables: `$CEF1-CF52` (97 entries hi), `$CE90-CEF0` Frequency tables (CORRECTED 2026-07-20 against the trace above:
(97 entries lo). Note codes are 1..97 indexing both tables. 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 CORRECTED 2026-07-20 (full byte-level trace of `$CD0E-$CD55`): the
per voice, with per-voice transpose and ADSR/filter state stored stream is **one byte per note event**, NOT (note, duration) pairs.
adjacently. No subroutines, no jump tables -- just a flat stream After the note plays, `$CD0E` re-reads the SAME byte at `($FC)` --
terminated by $00. 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 ### Two reset variants

103
assets/extractFlame.py Normal file
View file

@ -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/<port>.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()

63
assets/genDemoStreams.py Normal file
View file

@ -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 <stdint.h>\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()

View file

@ -119,13 +119,19 @@ def gen_tilebank():
def gen_sprites(): def gen_sprites():
"""sprites.png: 320x200, layout: """RETIRED 2026-07-20: sprites.png is now fully extracted from the
y= 0..23 : taxi cels 0-3 (drive) + 4-8 (transporter shrink) C64 dump by stuff/spacetaxi/extractSprites.py (taxi $C1/$C0/$DC/
y= 24..47 : passenger walk cels (row 1) $DD, passenger $C4-$CB/$D9, flame $6DB0 table, warp $E2-$E8 --
y= 48..71 : flame row (left blank; real cels come from raw.bin) 216x96, 4 rows). Regenerating placeholder art here would clobber
Sheet is exactly the 9x3 grid of 24x24 cels (216x72) so assetbake's the real sheet, the same trap gen_font has with the hand-edited
cell grid divides evenly. 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 img = new_canvas(9 * 24, 3 * 24) # 216x72: 9 cols x 3 rows of 24x24
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 128 B

BIN
generated/amiga/sprites/sprites.spc (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
generated/atarist/sprites/sprites.spc (Stored with Git LFS) Normal file

Binary file not shown.

BIN
generated/dos/sprites/sprites.spc (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
generated/iigs/sprites/sprites.spc (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

View file

@ -19,33 +19,178 @@
#include <string.h> #include <string.h>
#include "spacetaxi.h" #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 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 bool loadLevelByIndex(StGameT *game, uint8_t idx);
static void playingTick(StGameT *game);
// All 24 canonical Space Taxi levels (A..X), extracted from the C64 // All 24 canonical Space Taxi levels (A..X), extracted from the C64
// ROM via stuff/spacetaxi/romToLevel.py. level01 = scene 0 = "A", // ROM via stuff/spacetaxi/romToLevel.py. level01 = scene 0 = "A",
// level24 = scene 23 = "X". // level24 = scene 23 = "X".
static const char *gLevelPaths[] = { static const char *gLevelPaths[] = {
"DATA/levels/level01.dat", "DATA/levels/level02.dat", "levels/level01.dat", "levels/level02.dat",
"DATA/levels/level03.dat", "DATA/levels/level04.dat", "levels/level03.dat", "levels/level04.dat",
"DATA/levels/level05.dat", "DATA/levels/level06.dat", "levels/level05.dat", "levels/level06.dat",
"DATA/levels/level07.dat", "DATA/levels/level08.dat", "levels/level07.dat", "levels/level08.dat",
"DATA/levels/level09.dat", "DATA/levels/level10.dat", "levels/level09.dat", "levels/level10.dat",
"DATA/levels/level11.dat", "DATA/levels/level12.dat", "levels/level11.dat", "levels/level12.dat",
"DATA/levels/level13.dat", "DATA/levels/level14.dat", "levels/level13.dat", "levels/level14.dat",
"DATA/levels/level15.dat", "DATA/levels/level16.dat", "levels/level15.dat", "levels/level16.dat",
"DATA/levels/level17.dat", "DATA/levels/level18.dat", "levels/level17.dat", "levels/level18.dat",
"DATA/levels/level19.dat", "DATA/levels/level20.dat", "levels/level19.dat", "levels/level20.dat",
"DATA/levels/level21.dat", "DATA/levels/level22.dat", "levels/level21.dat", "levels/level22.dat",
"DATA/levels/level23.dat", "DATA/levels/level24.dat", "levels/level23.dat", "levels/level24.dat",
}; };
#define ST_LEVEL_COUNT (sizeof(gLevelPaths) / sizeof(gLevelPaths[0])) #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) { static void applyInput(StGameT *game) {
StTaxiT *t = &game->taxi; StTaxiT *t = &game->taxi;
int8_t jx; int8_t jx;
@ -66,15 +211,36 @@ static void applyInput(StGameT *game) {
left = jlKeyDown(KEY_LEFT) || jx < -32; left = jlKeyDown(KEY_LEFT) || jx < -32;
right = jlKeyDown(KEY_RIGHT) || jx > 32; right = jlKeyDown(KEY_RIGHT) || jx > 32;
// C64 Space Taxi gameplay only acts on the 4 directional bits of // FIRE is the LANDING GEAR ($63DD): a press EDGE while airborne
// $DC00; the fire button is masked out except on menu/title // toggles the gear with an SFX; pressing UP while parked on a pad
// screens. (Confirmed via disassembly at $6040-$60D6 in mem0801: // is the takeoff, which retracts the gear ($65AD AND #$FE). (The
// the main input handler only loads X/Y velocity templates when // old comment here claiming "fire is never tested in this path"
// L/R or U/D are held, fire is never tested in this path.) // was wrong -- $63F1-$6401 tests it every tick.)
// Menu fire-to-advance is handled by main()'s state switch. {
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->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0)); t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0));
t->thrusting = (up || down || left || right); 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 // Facing is purely cosmetic (sprite cel selection). Update when
// horizontal thrust is commanded; keep last facing otherwise so // horizontal thrust is commanded; keep last facing otherwise so
@ -103,14 +269,26 @@ static bool loadLevelByIndex(StGameT *game, uint8_t idx) {
int main(void) { int main(void) {
jlConfigT config; jlConfigT config;
jlSurfaceT *stage; 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 // Sprite codegen arena: after Phase 11 (shared-walker rewrite of
// the planar sprite path), sprites no longer consume arena bytes // the planar sprite path), sprites no longer consume arena bytes
// -- the per-cel work happens inside halSpriteDrawPlanes / // -- the per-cel work happens inside halSpriteDrawPlanes /
// halSpriteSavePlanes / halSpriteRestorePlanes as static lib code. // halSpriteSavePlanes / halSpriteRestorePlanes as static lib code.
// 32 KB is plenty for whatever other codegen needs are around. // 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 config.audioBytes = 32UL * 1024; // music + SFX
if (!jlInit(&config)) { if (!jlInit(&config)) {
@ -145,7 +323,7 @@ int main(void) {
// capture. Falls through to a black field if the asset is // capture. Falls through to a black field if the asset is
// missing. // missing.
jlLogF("spacetaxi: stLevelLoad title.dat ..."); 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", jlLogF("spacetaxi: title loaded, tilebank=%u",
(unsigned)game.level.tileBankId); (unsigned)game.level.tileBankId);
stRenderLevelChanged(); stRenderLevelChanged();
@ -154,12 +332,39 @@ int main(void) {
} }
jlLogFlush(); 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 (;;) { for (;;) {
uint8_t pendingTicks;
uint8_t tick;
jlInputPoll(); jlInputPoll();
if (jlKeyPressed(KEY_ESCAPE)) { if (jlKeyPressed(KEY_ESCAPE)) {
break; 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) { switch (game.state) {
case ST_STATE_TITLE: case ST_STATE_TITLE:
{ {
@ -185,7 +390,56 @@ int main(void) {
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) { jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
game.fareTarget = 1u; // C64 default ($48AF sets 1) game.fareTarget = 1u; // C64 default ($48AF sets 1)
game.state = ST_STATE_OPTIONS; 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; break;
} }
case ST_STATE_HIGHSCORES: case ST_STATE_HIGHSCORES:
@ -196,6 +450,7 @@ int main(void) {
if (jlKeyPressed(KEY_SPACE) || if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) { jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
stRenderLevelChanged(); stRenderLevelChanged();
stRenderTitleReset();
game.state = ST_STATE_TITLE; game.state = ST_STATE_TITLE;
} }
break; break;
@ -244,36 +499,18 @@ int main(void) {
break; break;
} }
case ST_STATE_PLAYING: case ST_STATE_PLAYING:
{ // Advance physics at the fixed 30 Hz cadence -- each
bool wasLanded; // playingTick handles the warp recede, physics, passenger,
// Transporter shrink-warp in progress: suspend input and // land SFX, and transporter-exit trigger. Stop early if a
// physics, just advance the recede animation; when it // tick changed state (crash game-over, level done).
// finishes, deliver any "UP PLEASE" fare and change screens. for (tick = 0u;
if (game.taxi.warpFrame > 0u) { tick < pendingTicks && game.state == ST_STATE_PLAYING;
if (stEngineWarpTick(&game)) { tick++) {
stPassengerTransporterExit(&game); playingTick(&game);
game.state = ST_STATE_LEVEL_DONE;
} }
break; // Continuous thrust SFX reflects the final tick's state.
}
wasLanded = game.taxi.landed;
applyInput(&game);
stEngineTick(&game);
stPassengerTick(&game);
// Edge-trigger SFX from state deltas.
stAudioSfxThrust(game.taxi.thrusting); 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; break;
}
case ST_STATE_LEVEL_DONE: case ST_STATE_LEVEL_DONE:
// No music to stop -- gameplay is silent. The C64 plays // No music to stop -- gameplay is silent. The C64 plays
// a score-screen jingle (song 7 / song 6) between levels; // a score-screen jingle (song 7 / song 6) between levels;
@ -283,9 +520,10 @@ int main(void) {
game.state = ST_STATE_PLAYING; game.state = ST_STATE_PLAYING;
} else { } else {
// ran out of levels -- back to title (player wins) // 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(); stRenderLevelChanged();
} }
stRenderTitleReset();
game.state = ST_STATE_TITLE; game.state = ST_STATE_TITLE;
} }
break; break;
@ -294,9 +532,10 @@ int main(void) {
// Restore the title tilemap so the press-start // Restore the title tilemap so the press-start
// overlay isn't sitting on top of the last-played // overlay isn't sitting on top of the last-played
// level's art. // level's art.
if (stLevelLoad(&game.level, "DATA/levels/title.dat")) { if (stLevelLoad(&game.level, "levels/title.dat")) {
stRenderLevelChanged(); stRenderLevelChanged();
} }
stRenderTitleReset();
game.state = ST_STATE_TITLE; game.state = ST_STATE_TITLE;
} }
break; break;
@ -305,9 +544,13 @@ int main(void) {
break; break;
} }
// Silence continuous thrust SFX whenever we're not actively // Silence continuous thrust SFX whenever no cab is flying
// playing (title, level-intro, game-over, level-done). // under thrust (title, menus, game-over, level-done). The
if (game.state != ST_STATE_PLAYING) { // 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); stAudioSfxThrust(false);
} }

View file

@ -20,8 +20,11 @@
// Ensoniq DOC channels (IIgs). The dispatch lives in // Ensoniq DOC channels (IIgs). The dispatch lives in
// stAudio.c with one entry point per voice command. // stAudio.c with one entry point per voice command.
// //
// input Joystick port 2 conventionally on the C64; we map // input Joystick port 2 conventionally on the C64: direction =
// thrust to fire and direction to all four cardinals. // 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 // Keyboard fallback: arrows + space for hosts without
// joysticks. // joysticks.
// //
@ -96,15 +99,10 @@
#define ST_SUBPIXEL_SHIFT 8 #define ST_SUBPIXEL_SHIFT 8
#define ST_SUBPIXEL (1 << ST_SUBPIXEL_SHIFT) #define ST_SUBPIXEL (1 << ST_SUBPIXEL_SHIFT)
// Taxi thrust cel cycling. The sprite asset // Taxi cels are the real C64 art (extractSprites.py): the $C0/$C1
// (assets/genPlaceholderArt.py) lays out 4 taxi cels: // right-facing and $DC/$DD left-facing pairs with the $6A95 per-frame
// cel 0 = idle (no thrust) // low-bit flicker; the engine flame is the separate direction-indexed
// cel 1..3 = thrust-flame frames (cycled while input is held) // sprite-2 cel set. Selection lives in stRender.c.
// thrustFrame on StTaxiT counts 0..(ST_THRUST_CEL_COUNT *
// ST_THRUST_CEL_TICKS - 1) while thrusting. Renderer divides by
// ST_THRUST_CEL_TICKS to pick cel 1, 2, or 3.
#define ST_THRUST_CEL_COUNT 3u
#define ST_THRUST_CEL_TICKS 2u
typedef enum { typedef enum {
@ -112,6 +110,7 @@ typedef enum {
ST_STATE_HIGHSCORES, // UP on title -> "THE IMMORTAL CABBIES" ($4C1F) ST_STATE_HIGHSCORES, // UP on title -> "THE IMMORTAL CABBIES" ($4C1F)
ST_STATE_INSTRUCTIONS, // DOWN on title -> author/about screen ($556A) ST_STATE_INSTRUCTIONS, // DOWN on title -> author/about screen ($556A)
ST_STATE_OPTIONS, // "GAME VARIATIONS" menu ($5295): pick cabbie count ST_STATE_OPTIONS, // "GAME VARIATIONS" menu ($5295): pick cabbie count
ST_STATE_DEMO, // rolling attract demo (recorded input playback)
ST_STATE_PLAYING, ST_STATE_PLAYING,
ST_STATE_LEVEL_DONE, ST_STATE_LEVEL_DONE,
ST_STATE_GAME_OVER ST_STATE_GAME_OVER
@ -188,10 +187,19 @@ typedef struct {
int16_t vx; // velocity accumulator (sub-pixel/frame) int16_t vx; // velocity accumulator (sub-pixel/frame)
int16_t vy; int16_t vy;
StFacingE facing; StFacingE facing;
uint8_t thrustFrame; // 0..1 parity bit driving cab-cel flicker
bool thrusting; // any directional input held this frame bool thrusting; // any directional input held this frame
int8_t thrustDx; // -1 left, 0 neutral, +1 right int8_t thrustDx; // -1 left, 0 neutral, +1 right
int8_t thrustDy; // -1 up, 0 neutral, +1 down 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 bool landed; // sitting on a pad
uint8_t onPad; // pad index if landed (0xFF if none) uint8_t onPad; // pad index if landed (0xFF if none)
// Death-animation countdown. >0 means crashed -- engine keeps the // Death-animation countdown. >0 means crashed -- engine keeps the
@ -200,6 +208,9 @@ typedef struct {
// branch"). Reaches 0 -> respawn (or game-over). Mirrors the // branch"). Reaches 0 -> respawn (or game-over). Mirrors the
// C64's $6B24/$6B4C phase-2/3 timing. // C64's $6B24/$6B4C phase-2/3 timing.
uint8_t crashTicks; 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 // Transporter shrink-warp progress (0 = not warping; 1..64 = the
// takeoff/shrink animation $5C91/$5CB9). During the warp the taxi // takeoff/shrink animation $5C91/$5CB9). During the warp the taxi
// recedes up through the opening; the screen advances when it ends. // recedes up through the opening; the screen advances when it ends.
@ -207,15 +218,40 @@ typedef struct {
} StTaxiT; } 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 { typedef struct {
bool active; bool active;
bool onboard; // in the cab (true) or waiting at pad (false) bool onboard; // in the cab (true) or waiting at pad (false)
uint8_t currentPad; // where they are if waiting uint8_t currentPad; // where they are if waiting
uint8_t destPad; uint8_t destPad;
int16_t x; // pixel position (for waiting/walking) int16_t x; // pixel position
int16_t y; int16_t y;
uint8_t walkPhase; // animation cel (0..3) // Passenger lifecycle, per the traced sequences: materialize
uint8_t walkDir; // 0 = walking right, 1 = walking left // ($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; } StPassengerT;
@ -227,6 +263,15 @@ typedef struct {
uint32_t score; uint32_t score;
uint8_t lives; uint8_t lives;
uint8_t levelIndex; 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 // Player-selectable fare quota (1..4). Mirrors C64 $7213 (the
// GAME-VARIATIONS "cabbies" count), set on the title screen via // GAME-VARIATIONS "cabbies" count), set on the title screen via
// LEFT/RIGHT joystick ($5310-$5328). This is the GLOBAL number of // 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 -- // after stLevelLoad even when the StLevelT pointer is unchanged --
// stRenderLevel's dirty-cache compares pointers, not contents. // stRenderLevel's dirty-cache compares pointers, not contents.
void stRenderLevelChanged(void); 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 // 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. // 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 stAudioPlayMusic(uint8_t musicId);
void stAudioStopMusic(void); void stAudioStopMusic(void);
void stAudioSfxThrust(bool on); void stAudioSfxThrust(bool on);
void stAudioSfxGear(void);
void stAudioSfxLand(void); void stAudioSfxLand(void);
void stAudioSfxPickup(void); void stAudioSfxPickup(void);
void stAudioSfxDropoff(void); void stAudioSfxDropoff(void);

View file

@ -34,6 +34,8 @@
#define ST_SFX_DROPOFF_TICKS 25u #define ST_SFX_DROPOFF_TICKS 25u
#define ST_SFX_LAND_HZ 90u #define ST_SFX_LAND_HZ 90u
#define ST_SFX_LAND_TICKS 12u #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)"): // 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) { void stAudioSfxLand(void) {
armSfx(ST_SFX_LAND_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_LAND_TICKS); armSfx(ST_SFX_LAND_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_LAND_TICKS);
} }

171
stDemoStreams.h Normal file
View file

@ -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 <stdint.h>
// 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

View file

@ -50,15 +50,15 @@
#define ST_TAXI_W_PX 24 #define ST_TAXI_W_PX 24
#define ST_TAXI_H_PX 24 #define ST_TAXI_H_PX 24
// Death-animation duration. Matches the C64 sequence at $6A72/$6B24/ // Death-animation duration, in GAME TICKS (the death countdown runs
// $6B4C: phase-1 keeps integrating Y velocity until the cab falls to // inside stEngineTick, now stepped at the fixed 30 Hz cadence). Matches
// the floor (row >= $DA = 218), then phase-2 walks the sprite cels // the C64 sequence at $6A72/$6B24/$6B4C: phase-1 integrates Y velocity
// $CC..$D1 with a rate that slows each step, then phase-3 holds for // until the cab falls to the floor (row >= $DA), phase-2 walks cels
// 70 frames before the finalize (DEC lives, blank sprite). 120 host // $CC..$D1 at a slowing rate, phase-3 holds ~70 video frames (~35 game
// frames here covers the full "fall + sit" sequence at the port's // ticks). 60 game ticks (~2 s) comfortably covers fall + impact walk +
// physics scale. The cab keeps falling under gravity during the // hold. The cab keeps falling under gravity during the countdown so the
// countdown so the visual matches the original "watch it drop". // visual matches the original "watch it drop".
#define ST_CRASH_ANIM_FRAMES 120u #define ST_CRASH_ANIM_FRAMES 60u
// Edge reflection at the visible playfield edges. The C64's $6AED // 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 // 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->landed = false;
t->onPad = 0xFFu; t->onPad = 0xFFu;
t->thrusting = false; 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 // Boot any in-flight passenger out of the cab; a respawn doesn't
// keep the fare. Waiting passengers (still on a pad) survive. // keep the fare. Waiting passengers (still on a pad) survive.
for (i = 0u; i < ST_MAX_PASSENGERS; i++) { 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; } if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; }
t->y += t->vy; t->y += t->vy;
if (t->y > maxY) { 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->y = maxY;
t->vy = 0; t->vy = 0;
t->crashImpacted = true;
} }
t->crashTicks--; t->crashTicks--;
if (t->crashTicks == 0u) { 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) { if (game->lives > 0u) {
game->lives--; game->lives--;
} }
@ -256,19 +271,6 @@ void stEngineTick(StGameT *game) {
t->vx = (int16_t)(t->vx + ax + (int16_t)L->xGrav); t->vx = (int16_t)(t->vx + ax + (int16_t)L->xGrav);
t->vy = (int16_t)(t->vy + ay + (int16_t)L->yGrav); 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 // Safety clamp on the velocity accumulator -- without this a long
// fall under gravity (or sustained one-way thrust against no // fall under gravity (or sustained one-way thrust against no
// collision) lets vx/vy wrap int16_t and reverse sign. // 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" // crashes into it -- this is the "land gently or die"
// mechanic. In the port's 8-bit sub-pixel scale that high- // mechanic. In the port's 8-bit sub-pixel scale that high-
// byte-zero test is vy < ST_SUBPIXEL (256). // 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); 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 ny = (int32_t)(feetY / ST_TILE_PIXELS) * ST_TILE_PIXELS * ST_SUBPIXEL
- (int32_t)ST_TAXI_H_PX * ST_SUBPIXEL; - (int32_t)ST_TAXI_H_PX * ST_SUBPIXEL;
t->vx = 0; t->vx = 0;
@ -335,6 +341,7 @@ void stEngineTick(StGameT *game) {
t->vy = ST_CRASH_FALL_VY; t->vy = ST_CRASH_FALL_VY;
t->thrusting = false; t->thrusting = false;
t->crashTicks = ST_CRASH_ANIM_FRAMES; t->crashTicks = ST_CRASH_ANIM_FRAMES;
t->crashImpacted = false;
} }
} else { } else {
t->landed = false; t->landed = false;
@ -382,10 +389,20 @@ bool stEngineInTransporter(const StGameT *game) {
// Transporter shrink-warp ($5C91 takeoff + $5CB9 pad2pad). The C64 walks // Transporter shrink-warp ($5C91 takeoff + $5CB9 pad2pad). The C64 walks
// the cab sprite through shrink cels $E2..$E9 (8 cels / 7 steps) while // 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 // 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 // it recedes. $5CB9 INCs $5A66 and gates the step on (counter & mask):
// last. Step boundaries (cumulative frames): 4,8,12,16,32,48,64. Total 64 // mask $03 (every 4) for the first steps, then $0F (every 16) from $E5.
// frames (~1.1s). The port has no shrink cels, so the renderer scales a //
// block by stEngineWarpStep instead. // 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 #define ST_WARP_TOTAL_FRAMES 64u
static const uint8_t kWarpStepFrame[8] = { 0u, 4u, 8u, 12u, 16u, 32u, 48u, 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.vx = 0;
game->taxi.vy = 0; game->taxi.vy = 0;
game->taxi.thrusting = false; 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) { if (t->warpFrame >= (uint8_t)ST_WARP_TOTAL_FRAMES) {
t->warpFrame = 0u; t->warpFrame = 0u;
jlAudioNoise(0u, 15u); // warp done: rumble off
return true; return true;
} }
return false; return false;

View file

@ -64,6 +64,10 @@ void stHudDraw(jlSurfaceT *stage, const StGameT *game) {
if (!p->active) { if (!p->active) {
continue; 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) { if (!p->onboard) {
snprintf(buf, sizeof(buf), "HEY TAXI!"); snprintf(buf, sizeof(buf), "HEY TAXI!");
} else if (p->destPad == ST_DEST_TRANSPORTER) { } else if (p->destPad == ST_DEST_TRANSPORTER) {

View file

@ -11,6 +11,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "joey/file.h"
#include "spacetaxi.h" #include "spacetaxi.h"
@ -47,7 +48,8 @@ bool stLevelLoad(StLevelT *out, const char *path) {
memset(out, 0, sizeof(*out)); 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) { if (fp == NULL) {
return false; return false;
} }

View file

@ -27,11 +27,13 @@
#include "spacetaxi.h" #include "spacetaxi.h"
#define ST_PASSENGER_W_PX 16
#define ST_PASSENGER_H_PX 16 #define ST_PASSENGER_H_PX 16
#define ST_PASSENGER_H_TILES (ST_PASSENGER_H_PX / ST_TILE_PIXELS) #define ST_PASSENGER_H_TILES (ST_PASSENGER_H_PX / ST_TILE_PIXELS)
#define ST_WALK_FRAMES 4u // The waiting passenger's in-place wave (and every beam/walk step)
#define ST_WALK_TICKS_PER_CEL 6u // 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: // 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 // a basic delivered fare adds '5' at the ones digit of the DDDD.DD HUD
@ -46,7 +48,7 @@
static uint32_t gRng; 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). // Frames until the next fare is forced to spawn (mirrors gDeathStage0Rng).
static int16_t gSpawnCountdown; static int16_t gSpawnCountdown;
// Pad deliveries completed on the current screen. Destination pads are // Pad deliveries completed on the current screen. Destination pads are
@ -56,12 +58,22 @@ static int16_t gSpawnCountdown;
static uint8_t gDeliveredThisScreen; static uint8_t gDeliveredThisScreen;
static bool arrivedAtTarget(const StPassengerT *p);
static void deliverFare(StGameT *game, StPassengerT *p); static void deliverFare(StGameT *game, StPassengerT *p);
static void pickDestination(StGameT *game, StPassengerT *p); static void pickDestination(StGameT *game, StPassengerT *p);
static void placeOnPad(StPassengerT *p, const StLevelT *level, uint8_t padIdx); static void placeOnPad(StPassengerT *p, const StLevelT *level, uint8_t padIdx);
static uint8_t rngPad(uint8_t n); static uint8_t rngPad(uint8_t n);
static void spawnPassenger(StGameT *game); 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) { 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) { static void spawnPassenger(StGameT *game) {
StPassengerT *p = &game->passengers[0]; StPassengerT *p = &game->passengers[0];
uint8_t padIdx; uint8_t padIdx;
@ -128,51 +152,17 @@ static void spawnPassenger(StGameT *game) {
p->active = true; p->active = true;
p->onboard = false; p->onboard = false;
p->walkPhase = 0u; p->phase = ST_PASS_BEAM_IN;
p->walkDir = 0u; p->waitPhase = 0u;
p->destPad = 0u; p->destPad = 0u;
placeOnPad(p, &game->level, padIdx); 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) { void stPassengerReset(StGameT *game) {
uint8_t i; uint8_t i;
gWalkTick = 0u; gWaveTicks = 0u;
gDeliveredThisScreen = 0u; gDeliveredThisScreen = 0u;
gSpawnCountdown = ST_SPAWN_CAP_FRAMES; gSpawnCountdown = ST_SPAWN_CAP_FRAMES;
// Deterministic per-screen seed: varies the pad sequence level to // Deterministic per-screen seed: varies the pad sequence level to
@ -206,26 +196,113 @@ void stPassengerTick(StGameT *game) {
return; return;
} }
if (!p->onboard) { // One shared step clock (the C64's 3-game-tick cadence, $715E == 3)
// Walk in place while waiting. // drives every phase: beams, wave, and walk strides. Called once
if (++gWalkTick >= ST_WALK_TICKS_PER_CEL) { // per fixed game tick, so this is a plain tick count.
gWalkTick = 0u; {
walkPassenger(game, p); bool stepDue = (++gWaveTicks >= ST_WAVE_STEP_TICKS);
if (stepDue) {
gWaveTicks = 0u;
}
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);
} }
// Board when the taxi has landed on their pad.
if (t->landed && t->onPad == p->currentPad) { 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->onboard = true;
p->phase = ST_PASS_RIDING;
p->waitPhase = 0u;
pickDestination(game, p); pickDestination(game, p);
stAudioSfxPickup(); stAudioSfxPickup();
} }
} else if (p->destPad != ST_DEST_TRANSPORTER) { break;
// Deliver by landing on the destination pad. (UP-PLEASE fares are
// delivered on the transporter fly-up; see stPassengerTransporterExit.) case ST_PASS_RIDING:
if (t->landed && // 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 < game->level.padCount &&
t->onPad == p->destPad) { 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); deliverFare(game, p);
} }
break;
}
} }
} }

View file

@ -33,9 +33,10 @@
// DOS 8.3 filename limit: "tilebank0.tbk" is 9.3, fopen fails under // 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 // DOSBox strict 8.3. Shortened to "tbank%u.tbk" (6.3) so all four
// targets can use the same filenames without per-platform aliasing. // targets can use the same filenames without per-platform aliasing.
#define ST_TILE_BANK_PATH_FMT "DATA/tiles/tbank%u.tbk" #define ST_TILE_BANK_PATH_FMT "tiles/tbank%u.tbk"
#define ST_SPRITE_SHEET_PATH "DATA/sprites/sprites.spr" #define ST_SPRITE_SHEET_PATH "sprites/sprites.spr"
#define ST_FONT_PATH "DATA/font.tbk" #define ST_SPRITE_SHEET_SPC "sprites/sprites.spc"
#define ST_FONT_PATH "font.tbk"
#define ST_TILE_BANK_MAX 256u #define ST_TILE_BANK_MAX 256u
@ -52,29 +53,33 @@
// cels), row 2 is the flame (8 cels). Cells lay out left-to-right // cels), row 2 is the flame (8 cels). Cells lay out left-to-right
// top-to-bottom in the .spr blob's cellCount = 27. // top-to-bottom in the .spr blob's cellCount = 27.
#define ST_SPRITE_SHEET_COLS 9 #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_TAXI_FIRST 0
#define ST_SPRITE_PASS_FIRST 9 #define ST_SPRITE_PASS_FIRST 11
#define ST_SPRITE_FLAME_FIRST 18 #define ST_SPRITE_FLAME_FIRST 22
// Taxi sprite: 24x24 px = 3 tiles wide x 3 tiles tall. The port's // Taxi sprite: 24x24 px = 3 tiles wide x 3 tiles tall. The port's
// sprite asset (genPlaceholderArt.py) authors 4 cels: // sprite asset (extractSprites.py, real C64 art from raw.bin) lays
// cel 0: idle cab (no thrust) // out sheet row 0 in the live sprite-pointer order:
// cel 1: thrust frame A // cel 0 = $C1 (right, gear DOWN) cel 1 = $C0 (right, gear up)
// cel 2: thrust frame B // cel 2 = $DC (left, gear up) cel 3 = $DD (left, gear DOWN)
// cel 3: thrust frame C // $619B selects the base pair by held direction (LEFT -> $DC pair,
// Index: thrusting ? (1 + thrustFrame/ST_THRUST_CEL_TICKS) : 0. The // RIGHT -> $C0 pair, otherwise keep the last facing) and preserves
// C64's $619B selects between LEFT-facing ($DC) and RIGHT-facing // bit 0 = the LANDING GEAR state (fire-toggled, $63DD). The in-
// ($C0) cab bases with a 1-bit flicker on each, but the port's asset // flight body NEVER animates -- the cel is a static function of
// doesn't carry left/right variants -- left/right facing isn't // (facing, gearDown):
// visually reflected until the sheet adds them. // 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_W_PX 24
#define ST_TAXI_H_PX 24 #define ST_TAXI_H_PX 24
#define ST_TAXI_CEL_COUNT 4 #define ST_TAXI_CEL_COUNT 4
// Transporter shrink-warp cels: the cab receding, in the free row-0 slots // Transporter shrink-warp cels (sheet row 3): the SEVEN drawn cels
// right after the 4 drive cels. The C64 walks 8 shrink cels ($E2..$E9); // $E2..$E8 of the $5CB9 chain, identity-mapped to warp steps 0..6.
// the port approximates with 5 sizes and maps the 8 warp steps onto them. // $5CDA ends the warp when the pointer REACHES $E9 and hides the
#define ST_SPRITE_WARP_FIRST (ST_SPRITE_TAXI_FIRST + ST_TAXI_CEL_COUNT) // sprite ($718E = 0) -- $E9 is never displayed, so step 7 draws
#define ST_WARP_CEL_COUNT 5 // 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 // Flame placeholder fallback (only used if real sprite-2 cel is
// missing): a small bright rectangle below the cab while thrusting. // 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 // Real flame sprite cels (24x24, from raw.bin sprite ptrs in $6DB0
// table). Indexed by direction-mask -> cel via kFlameCelByDirMask // table). Indexed by direction-mask -> cel via kFlameCelByDirMask
// below. $6D6A's parity bit ($716C) flickers the flame off every // 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_W_PX 24
#define ST_FLAME_CEL_H_PX 24 #define ST_FLAME_CEL_H_PX 24
#define ST_FLAME_CEL_COUNT 8 #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 // Passenger: 24x24 (C64 hardware sprite size is 24x21; we pad 3 rows
// transparent at the bottom in the asset extraction to keep a square // transparent at the bottom in the asset extraction to keep a square
@ -101,11 +112,28 @@
// $67A6 incrementing gSpr1PtrShadow from $C6 toward $CC. // $67A6 incrementing gSpr1PtrShadow from $C6 toward $CC.
#define ST_PASSENGER_W_PX 24 #define ST_PASSENGER_W_PX 24
#define ST_PASSENGER_H_PX 24 #define ST_PASSENGER_H_PX 24
#define ST_PASSENGER_CEL_COUNT 9 #define ST_PASSENGER_CEL_COUNT 11
#define ST_PASSENGER_CEL_WALK0 0 #define ST_PASSENGER_CEL_WALK0 0 /* $C4 walk LEFT a */
#define ST_PASSENGER_CEL_WALK1 1 #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_BOARD0 2 /* maps to C64 ptr $C6 */
#define ST_PASSENGER_CEL_SPARKLE 8 /* C64 ptr $D9 -- 3rd sparkle cel */ #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). // 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) #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; bool bankLoaded;
jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT]; jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT];
jlSpriteT *warpCels[ST_WARP_CEL_COUNT]; jlSpriteT *warpCels[ST_WARP_CEL_COUNT];
jlSpriteT *deathCels[ST_DEATH_CEL_COUNT];
jlSpriteT *flameCels[ST_FLAME_CEL_COUNT]; jlSpriteT *flameCels[ST_FLAME_CEL_COUNT];
jlSpriteBackupT flameBackup; jlSpriteBackupT flameBackup;
uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2))); 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))); uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
bool taxiHasBackup; bool taxiHasBackup;
bool passengerHasBackup[ST_MAX_PASSENGERS]; 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 // Tilemap repaint gating: the static playfield art only needs to
// be blitted to the stage once per scene change, not every frame. // be blitted to the stage once per scene change, not every frame.
// Per-frame full repaint blows the per-frame budget on emulated // 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 loadTileBank(uint8_t bankId);
static bool loadSpriteSheet(void); static bool loadSpriteSheet(jlSurfaceT *stage);
static bool loadFontSheet(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 buildAsciiMap(void);
static void destroySprites(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 // parks all hardware sprite slots at (col $AA, row $8C) so they don't
// flash garbage when the title is first shown. // flash garbage when the title is first shown.
// Recorded (input_mask, frame_duration) pairs at C64 $0902. Played by // (The fabricated title "demo physics" stage and its truncated copy
// $48F2 once the title intro reaches the demo phase. // of demo stream 01 were deleted 2026-07-21: the real C64 never flies
static const uint8_t kTitleDemoScript[256] = { // the cab around the title after takeoff -- it enters the ATTRACT
0x80, 0x17, 0x88, 0x0A, 0x80, 0x0A, 0x84, 0x04, 0x85, 0x03, 0x81, 0x03, 0x80, 0x0A, 0x88, 0x04, // DEMO, now a proper game state (ST_STATE_DEMO) driven by the full
0x80, 0x01, 0x81, 0x0E, 0x80, 0x0D, 0x82, 0x02, 0x80, 0x0A, 0x81, 0x09, 0x85, 0x07, 0x84, 0x05, // recorded input streams in stDemoStreams.h.)
0x80, 0x02, 0x88, 0x05, 0x8A, 0x04, 0x82, 0x03, 0x80, 0x05, 0x85, 0x10, 0x81, 0x08, 0x89, 0x02,
0x88, 0x0E, 0x8A, 0x03, 0x88, 0x06, 0x81, 0x07, 0x80, 0x12, 0x82, 0x05, 0x80, 0x04, 0x81, 0x11,
0x80, 0x04, 0x82, 0x04, 0x86, 0x09, 0x84, 0x02, 0x95, 0x07, 0x85, 0x04, 0x81, 0x01, 0x80, 0x43,
0x81, 0x0A, 0x84, 0x05, 0x86, 0x03, 0x82, 0x06, 0x80, 0x07, 0x81, 0x09, 0x80, 0x04, 0x88, 0x0B,
0x80, 0x09, 0x84, 0x04, 0x80, 0x09, 0x81, 0x01, 0x89, 0x04, 0x80, 0x08, 0x85, 0x04, 0x81, 0x01,
0x80, 0x05, 0x81, 0x15, 0x80, 0x0C, 0x82, 0x02, 0x86, 0x07, 0x80, 0x05, 0x81, 0x09, 0x80, 0x13,
0x84, 0x01, 0x85, 0x03, 0x80, 0x06, 0x88, 0x0B, 0x80, 0x16, 0x81, 0x0A, 0x80, 0x01, 0x88, 0x04,
0x80, 0x0C, 0x81, 0x03, 0x85, 0x02, 0x84, 0x03, 0x85, 0x0C, 0x81, 0x01, 0x80, 0x0D, 0x82, 0x07,
0x80, 0x06, 0x81, 0x02, 0x89, 0x0C, 0x88, 0x02, 0x80, 0x05, 0x82, 0x04, 0x80, 0x0A, 0x81, 0x04,
0x85, 0x01, 0x84, 0x04, 0x80, 0x0D, 0x81, 0x05, 0x89, 0x0E, 0x81, 0x01, 0x80, 0x11, 0x84, 0x17,
0x80, 0x05, 0x81, 0x04, 0x80, 0x19, 0x81, 0x03, 0x89, 0x01, 0x88, 0x0E, 0x80, 0x07, 0x84, 0x05,
0x80, 0x0C, 0x81, 0x0D, 0x80, 0x19, 0x88, 0x01, 0x80, 0x02, 0x88, 0x04, 0x80, 0x1B, 0x88, 0x0A,
0x89, 0x0B, 0x81, 0x14, 0x80, 0x02, 0x82, 0x03, 0x80, 0x05, 0x84, 0x03, 0x85, 0x09, 0x84, 0x05,
0x85, 0x05, 0x84, 0x02, 0x86, 0x08, 0x84, 0x01, 0x80, 0x01, 0x81, 0x03, 0x89, 0x12, 0x81, 0x08,
};
// C64 reference values straight from the asm at $4A03 dispatch + // C64 reference values straight from the asm at $4A03 dispatch +
// titleSpriteSetup ($4525) + sprite-init tables ($4994/$49AC). // 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_WALK 3u // $4A24
#define ST_TITLE_STAGE_HANDOFF 4u // $4A45 -> $67A6 #define ST_TITLE_STAGE_HANDOFF 4u // $4A45 -> $67A6
#define ST_TITLE_STAGE_LIFTOFF 5u // $4A48 #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_SPARKLE_FRAMES 0x5Au // $47CC LDA #$5A STA $473F
#define ST_TITLE_WALK_TICK_RELOAD 3u // $715E observed in raw.bin #define ST_TITLE_WALK_TICK_RELOAD 3u // $715E observed in raw.bin
// Walk target = gPadHoverXCol ($715F) = $28 set at $47D8. // 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. // $66D6 sparkle cel table -- cycled by $66B7 during stage 2.
// Each entry shown for $715E (= 3) frames; one full cycle = 12 frames. // 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 // Map direction-mask (CIA1 PortA bits after EOR #$FF: bit0=UP bit1=DOWN
// bit2=LEFT bit3=RIGHT) to a flameCels[] index, mirroring the C64 // bit2=LEFT bit3=RIGHT) to a flameCels[] index, mirroring the C64
@ -267,22 +297,16 @@ typedef struct {
bool cabVisible; bool cabVisible;
bool flameVisible; // sprite 2 from $6D6A flameSpriteUpdate bool flameVisible; // sprite 2 from $6D6A flameSpriteUpdate
uint8_t flameDirMask; // mirrors $716A passed into $6DB0,X 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. // 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 // Logo flip-book + color cycle. The "SPACE TAXI" logo is 103 cells
// of char $84; the C64 ($4827) copies one of the flip frames // of char $84; the C64 ($4827) copies one of the flip frames
// $85..$88 over char $84's bitmap every 4th frame, walking the // $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 // 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. // 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 logoFlipIdx; // mirrors $48A5 (0..5 ping-pong index)
uint8_t logoColorIdx; // mirrors $48A4 (0..7 color cycle index) uint8_t logoColorIdx; // mirrors $48A4 (0..7 color cycle index)
bool logoNeedsPaint; // flip/color changed (or tilemap repainted)
} StTitleStateT; } StTitleStateT;
// The single animated logo character. Its bitmap is flip-booked through // The single animated logo character. Its bitmap is flip-booked through
@ -355,14 +379,45 @@ void stRenderInit(jlSurfaceT *stage) {
jlPaletteSet(stage, 0u, kDefaultPalette); jlPaletteSet(stage, 0u, kDefaultPalette);
jlScbSetRange(stage, 0u, (uint16_t)(SURFACE_HEIGHT - 1u), 0u); jlScbSetRange(stage, 0u, (uint16_t)(SURFACE_HEIGHT - 1u), 0u);
// Tile bank is loaded on demand in stRenderLevel (per-level). // Startup feedback: the IIgs spends several seconds streaming the
if (!loadSpriteSheet()) { // banks and compiling sprite cels to their asm routines, all of
jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH); // 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)) { if (!loadFontSheet(stage)) {
jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH); jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH);
} }
buildAsciiMap(); 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(); titleReset();
} }
@ -478,14 +533,12 @@ static void titleReset(void) {
gTitle.cabVisible = true; gTitle.cabVisible = true;
gTitle.passengerVisible = true; gTitle.passengerVisible = true;
gTitle.flameVisible = false; gTitle.flameVisible = false;
// Demo state: initial script pos / pair-0 duration preload. // Force the first tick to be an animation step (3 -> 0 on first ++)
gTitle.scriptIdx = 0u; // AND force a logo paint on the first render frame so the flip
gTitle.scriptTimer = kTitleDemoScript[1]; // frame + color appear immediately (the tilemap was just repainted
gTitle.demoX = (int32_t)ST_TITLE_CAB_SX_INIT * ST_SUBPIXEL; // by stRenderLevelChanged, erasing any prior logo cells).
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.
gTitle.logoFlipDiv = 3u; 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} // 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 // (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. // 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 kFlip[6] = { 1u, 2u, 3u, 4u, 3u, 2u }; // $48A7
static const uint8_t kColor[8] = { 0x02u, 0x08u, 0x07u, 0x05u, static const uint8_t kColor[8] = { 0x02u, 0x08u, 0x07u, 0x05u,
0x06u, 0x0Eu, 0x03u, 0x04u }; // $489C 0x06u, 0x0Eu, 0x03u, 0x04u }; // $489C
@ -509,14 +587,11 @@ static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) {
uint8_t by; uint8_t by;
size_t i; size_t i;
// $4827: only step the flip-book every 4th frame. if (!gTitle.logoNeedsPaint) {
gTitle.logoFlipDiv = (uint8_t)((gTitle.logoFlipDiv + 1u) & 3u);
if (gTitle.logoFlipDiv != 0u) {
return; 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]); ch = (uint8_t)(ST_LOGO_CHAR + kFlip[gTitle.logoFlipIdx]);
fg = kColor[gTitle.logoColorIdx]; fg = kColor[gTitle.logoColorIdx];
tile = gRender.tileValid[ch] ? &gRender.tiles[ch] : NULL; 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 // $4A03 dispatcher. One iteration per host frame, exactly as the C64
// runs $4A03 from $47E4 each iteration of the title intro loop. // runs $4A03 from $47E4 each iteration of the title intro loop.
static void titleTick(void) { 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) { switch (gTitle.stage) {
case ST_TITLE_STAGE_SPARKLE: case ST_TITLE_STAGE_SPARKLE:
// $4A17: JSR $66B7 / DEC $473F / BEQ -> INC $7163. // $4A17: JSR $66B7 / DEC $473F / BEQ -> INC $7163.
@ -600,7 +670,7 @@ static void titleTick(void) {
if (gTitle.decayTimer == 0u) { if (gTitle.decayTimer == 0u) {
gTitle.decayTimer = gTitle.decayReload; gTitle.decayTimer = gTitle.decayReload;
gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u); gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u);
gTitle.passengerPtr = kTitleSparkleCels[gTitle.sparkleIdx]; gTitle.passengerPtr = kPassengerWaveCels[gTitle.sparkleIdx];
} }
if (gTitle.introFrameCount > 0u) { if (gTitle.introFrameCount > 0u) {
gTitle.introFrameCount--; gTitle.introFrameCount--;
@ -652,90 +722,23 @@ static void titleTick(void) {
gTitle.cabPtr = 0xC0; gTitle.cabPtr = 0xC0;
gTitle.flameDirMask = 0x01; // UP only gTitle.flameDirMask = 0x01; // UP only
gTitle.flameVisible = true; gTitle.flameVisible = true;
gTitle.flameParity ^= 1u; // $6D8B-$6D90 toggle on every call
if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) { if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) {
// $4A64-$4A6C: INC gDeathStage; silence voice 3. // $4A64-$4A6C: INC gDeathStage; silence voice 3.
gTitle.cabVisible = false; gTitle.cabVisible = false;
gTitle.flameVisible = false; gTitle.flameVisible = false;
gTitle.stage = ST_TITLE_STAGE_DEMO; // $4A64 INCs gDeathStage out of the intro: on the C64 the
// Demo continues from wherever the cab is -- the C64 does // machine now enters the rolling attract demo. The port
// NOT reset the cab position. Physics + gravity bring it // signals spacetaxi.c via stRenderTitleIntroDone().
// back into view from the top. gTitle.stage = ST_TITLE_STAGE_DONE;
gTitle.demoX = (int32_t)gTitle.cabSx * ST_SUBPIXEL;
gTitle.demoY = (int32_t)gTitle.cabSy * ST_SUBPIXEL;
gTitle.demoVx = 0;
gTitle.demoVy = (int16_t)(-2 * ST_SUBPIXEL); // takeoff momentum
} }
break; break;
case ST_TITLE_STAGE_DEMO: case ST_TITLE_STAGE_DONE:
default: default:
{ // Intro finished -- the title art sits static (logo cycle
// Demo phase = physicsTick with $48F2 sourcing input from // continues); spacetaxi.c switches to ST_STATE_DEMO.
// kTitleDemoScript. $48F2 advance pattern at $4903-$4923:
// DEC $4740; BNE return; -- still on current pair
// LDY #$03; LDA ($3F),Y; -- new timer from NEXT pair
// STA $4740;
// $3F += 2; -- advance to next pair
uint8_t mask;
int8_t dx;
int8_t dy;
int32_t maxX = (int32_t)(SURFACE_WIDTH - ST_TAXI_W_PX) * ST_SUBPIXEL;
int32_t maxY = (int32_t)(SURFACE_HEIGHT - ST_TAXI_H_PX) * ST_SUBPIXEL;
if (gTitle.scriptTimer > 0u) {
gTitle.scriptTimer--;
}
if (gTitle.scriptTimer == 0u) {
gTitle.scriptIdx = (uint16_t)((gTitle.scriptIdx + 2u) & 0xFFu);
gTitle.scriptTimer =
kTitleDemoScript[(gTitle.scriptIdx + 1u) & 0xFFu];
if (gTitle.scriptTimer == 0u) {
gTitle.scriptTimer = 1u;
}
}
mask = kTitleDemoScript[gTitle.scriptIdx & 0xFFu];
// CIA1 PortA bits (after EOR #$FF at $6043):
// bit0 = UP, bit1 = DOWN, bit2 = LEFT, bit3 = RIGHT, bit4 = FIRE.
dx = (int8_t)(((mask & 0x08u) ? 1 : 0) - ((mask & 0x04u) ? 1 : 0));
dy = (int8_t)(((mask & 0x02u) ? 1 : 0) - ((mask & 0x01u) ? 1 : 0));
// Level-A defaults for the demo: xAccel/yAccel = 14, yGrav = 1.
gTitle.demoVx = (int16_t)(gTitle.demoVx + dx * 14);
gTitle.demoVy = (int16_t)(gTitle.demoVy + dy * 14 + 1);
if (gTitle.demoVx > 512) gTitle.demoVx = 512;
if (gTitle.demoVx < -512) gTitle.demoVx = -512;
if (gTitle.demoVy > 512) gTitle.demoVy = 512;
if (gTitle.demoVy < -512) gTitle.demoVy = -512;
gTitle.demoX += gTitle.demoVx;
gTitle.demoY += gTitle.demoVy;
if (gTitle.demoX < 0) { gTitle.demoX = 0; gTitle.demoVx = 0; }
if (gTitle.demoX > maxX) { gTitle.demoX = maxX; gTitle.demoVx = 0; }
// Y can go negative (cab off-screen above) -- let it. Only
// clamp at the bottom.
if (gTitle.demoY > maxY) { gTitle.demoY = maxY; gTitle.demoVy = 0; }
gTitle.cabSx = (int16_t)(gTitle.demoX >> ST_SUBPIXEL_SHIFT);
gTitle.cabSy = (int16_t)(gTitle.demoY >> ST_SUBPIXEL_SHIFT);
// Only re-show the cab once it's back inside visible Y.
if (gTitle.cabSy >= (int16_t)ST_SPRITE_Y_OFFSET) {
gTitle.cabVisible = true;
}
// Flame: per $6D6A, on whenever any direction bit is set in the
// mask. Build the mask from dx/dy the same way the C64 reads
// CIA1 PortA: bit0=UP bit1=DOWN bit2=LEFT bit3=RIGHT.
{
uint8_t mask = 0u;
if (dy < 0) mask |= 0x01u; // UP
if (dy > 0) mask |= 0x02u; // DOWN
if (dx < 0) mask |= 0x04u; // LEFT
if (dx > 0) mask |= 0x08u; // RIGHT
gTitle.flameDirMask = mask;
gTitle.flameVisible = (mask != 0u);
if (gTitle.flameVisible) {
gTitle.flameParity ^= 1u;
}
}
break; break;
} }
}
} }
@ -778,10 +781,12 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
// (e.g., transitioned from gameplay back to title) -- otherwise // (e.g., transitioned from gameplay back to title) -- otherwise
// it's a fast no-op. // it's a fast no-op.
stRenderLevel(stage, &game->level); stRenderLevel(stage, &game->level);
// Flip-book + color-cycle the "SPACE TAXI" logo (char $84 cells) // Paint the "SPACE TAXI" logo (char $84 cells) at its current
// over the freshly-painted tilemap, BEFORE the demo sprites // flip frame + color over the freshly-painted tilemap, BEFORE
// save-under and draw, so the sprites overlay the live logo. // the sprites save-under and draw so they overlay it. The flip
titleAnimateLogo(stage, &game->level); // 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 // No "fares per game" overlay on the title: the C64 has a
// separate options/menu scene driven by $5295 (header text) // separate options/menu scene driven by $5295 (header text)
// and $52E1 / $533C (the "1 2 3 4" digit row at row 2 col 28 // 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 // text -- only "BY JOHN F. BUTCHER" credits and the
// UP=hiscore / DOWN=instructions / FIRE=begin joystick line. // UP=hiscore / DOWN=instructions / FIRE=begin joystick line.
// Options-menu scene is a separate state to be built later. // Options-menu scene is a separate state to be built later.
// Run the C64 title intro state machine (sparkle -> passenger // The intro state machine (sparkle -> passenger walk -> taxi
// walk -> taxi takeoff -> scripted demo) and draw whichever // takeoff) is advanced on the fixed game-tick clock in the main
// sprites are visible this frame. // loop (stRenderTitleAdvance), not here -- this pass only DRAWS
titleTick(); // whichever sprites the current intro state has visible.
// sprite-X -> visible col = sprite-X - 24 (C64 border offset). // sprite-X -> visible col = sprite-X - 24 (C64 border offset).
// sprite-Y -> visible row = sprite-Y - 50 (top border). // sprite-Y -> visible row = sprite-Y - 50 (top border).
// Draw lowest priority first: flame (sprite 2), then passenger // Draw lowest priority first: flame (sprite 2), then passenger
// (sprite 1), then cab (sprite 0) on top. // (sprite 1), then cab (sprite 0) on top.
if (gTitle.flameVisible && (gTitle.flameParity & 1u) && if (gTitle.flameVisible && flameStrobeOn() &&
gTitle.flameDirMask < 16u) { gTitle.flameDirMask < 16u) {
int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask]; int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask];
if (cel >= 0 && gRender.flameCels[cel] != NULL) { if (cel >= 0 && gRender.flameCels[cel] != NULL) {
@ -812,15 +817,7 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
} }
} }
if (gTitle.passengerVisible) { if (gTitle.passengerVisible) {
// Map live C64 sprite ptr -> sprite-sheet cel index. int16_t cel = passCelForPtr(gTitle.passengerPtr);
// $C4..$CB -> cels 0..7 (walk-LEFT cels then boarding)
// $D9 -> cel 8 (3rd sparkle cel from $66D6)
int16_t cel;
if (gTitle.passengerPtr == 0xD9) {
cel = ST_PASSENGER_CEL_SPARKLE;
} else {
cel = (int16_t)gTitle.passengerPtr - 0xC4;
}
if (cel < 0) { if (cel < 0) {
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 // Restore prev-frame sprite backups FIRST so the static tilemap
// shows through where the taxi/passenger used to be. Then we'll // shows through where the taxi/passenger/flame used to be. Then
// save+draw at the new positions below. // 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) { if (gRender.taxiHasBackup) {
jlSpriteRestoreUnder(stage, &gRender.taxiBackup); jlSpriteRestoreUnder(stage, &gRender.taxiBackup);
gRender.taxiHasBackup = false; gRender.taxiHasBackup = false;
} }
if (gRender.flameHasBackup) {
jlSpriteRestoreUnder(stage, &gRender.flameBackup);
gRender.flameHasBackup = false;
}
for (i = 0u; i < ST_MAX_PASSENGERS; i++) { for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
if (gRender.passengerHasBackup[i]) { if (gRender.passengerHasBackup[i]) {
jlSpriteRestoreUnder(stage, &gRender.passengerBackup[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); px = (int16_t)(game->taxi.x >> ST_SUBPIXEL_SHIFT);
py = (int16_t)(game->taxi.y >> ST_SUBPIXEL_SHIFT); py = (int16_t)(game->taxi.y >> ST_SUBPIXEL_SHIFT);
// Cel = idle (0) when not thrusting, otherwise cycle through the // $619B cel select: facing picks the $C0/$C1 (right) or $DC/$DD
// three authored flame frames (1, 2, 3). Falls back to cel 0 if // (left) pair; bit 0 is the LANDING GEAR state. The body is a
// a thrust cel isn't authored. During the crash anim ($6B24/$6B4C // STATIC cel -- no flicker of any kind. Sheet order
// in the C64) the original walks sprite-0 through death cels // [$C1,$C0,$DC,$DD]:
// $CC..$D1 at a slowing rate; the port doesn't have those cels // right: ptr = $C0|gear -> cel 1 - gear
// so the cab keeps its normal sprite as it falls -- the falling // left: ptr = $DC|gear -> cel 2 + gear
// motion + crash SFX + voice-1 scream are the cue. // (The crash anim uses the dedicated death cels below.)
if (game->taxi.thrusting) { if (game->taxi.facing == ST_DIR_LEFT) {
taxiCel = (uint8_t)(1u + taxiCel = (uint8_t)(game->taxi.gearDown ? 3u : 2u);
(game->taxi.thrustFrame / ST_THRUST_CEL_TICKS));
if (taxiCel >= ST_TAXI_CEL_COUNT) {
taxiCel = 0u;
}
} else { } else {
taxiCel = 0u; taxiCel = (uint8_t)(game->taxi.gearDown ? 0u : 1u);
} }
if (gRender.taxiCels[taxiCel] == NULL) { if (gRender.taxiCels[taxiCel] == NULL) {
taxiCel = 0u; taxiCel = 0u;
} }
if (game->taxi.warpFrame > 0u) { // Engine flame: C64 sprite 2 ($6D6A) at (cab_x - 2, cab_y),
// Transporter shrink-warp: draw the cab-receding cel for this warp // cel indexed by the $716A direction mask through the $6DB0
// step (the C64 walks shrink cels $E2..$E9; the port has 5 sizes, // table, drawn on alternate frames only (the parity flicker),
// one selected per step). Uses the taxi's save-under so it undraws // hidden when no input is held. Drawn BEFORE the cab so the cab
// cleanly next frame -- no tilemap repaint needed. // (sprite 0) keeps VIC priority on top.
uint8_t step = stEngineWarpStep(game); /* 0..7 */ if (game->taxi.warpFrame == 0u && game->taxi.thrusting &&
uint8_t wcel = (uint8_t)((uint16_t)step * ST_WARP_CEL_COUNT / 8u); !game->taxi.landed && flameStrobeOn() &&
if (wcel >= ST_WARP_CEL_COUNT) { game->taxi.dirMask < 16u) {
wcel = (uint8_t)(ST_WARP_CEL_COUNT - 1u); 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.taxiBackup);
gRender.taxiHasBackup = true; gRender.taxiHasBackup = true;
} }
} else if (gRender.taxiCels[taxiCel] != NULL) { } else if (gRender.taxiCels[taxiCel] != NULL) {
// Save-under + draw. Backup is replayed at the start of next // Save-under + draw. Backup is replayed at the start of next
// frame above to undraw cleanly. // frame above to undraw cleanly.
gRender.deathActive = false;
jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py, jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py,
&gRender.taxiBackup); &gRender.taxiBackup);
gRender.taxiHasBackup = true; 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, jlFillRect(stage, px, py, ST_TAXI_W_PX, ST_TAXI_H_PX,
game->level.sprite0Color); game->level.sprite0Color);
gRender.tilemapDirty = true; 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) { if (game->taxi.thrusting) {
jlFillRect(stage, jlFillRect(stage,
(int16_t)(px + ST_FLAME_OFFSET_X_PX), (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++) { for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
const StPassengerT *p = &game->passengers[i]; const StPassengerT *p = &game->passengers[i];
jlSpriteT *cel; jlSpriteT *cel;
if (!p->active || p->onboard) { int16_t pcel;
continue; // onboard passengers ride invisibly inside the cab 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) { if (cel == NULL) {
jlFillRect(stage, p->x, p->y, jlFillRect(stage, p->x, p->y,
ST_PASSENGER_W_PX, ST_PASSENGER_H_PX, 8u); ST_PASSENGER_W_PX, ST_PASSENGER_H_PX, 8u);
@ -1035,6 +1100,13 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
gRender.passengerHasBackup[i] = true; 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) { if (game->state == ST_STATE_LEVEL_DONE) {
int16_t midY = (int16_t)(10 * ST_TILE_PIXELS); int16_t midY = (int16_t)(10 * ST_TILE_PIXELS);
jlFillRect(stage, 0, midY, SURFACE_WIDTH, jlFillRect(stage, 0, midY, SURFACE_WIDTH,
@ -1082,37 +1154,99 @@ static bool loadTileBank(uint8_t bankId) {
} }
static bool loadSpriteSheet(void) { static bool loadSpriteSheet(jlSurfaceT *stage) {
jlSpriteT *cels[ST_SPRITE_SHEET_CELS]; jlSpriteT *cels[ST_SPRITE_SHEET_CELS];
uint16_t count; uint16_t count;
uint16_t i; 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 // chunky 4bpp blobs) in PNG reading order: row 0 taxi, row 1
// passenger, row 2 flame. The padding slots between named // passenger, row 2 flame, row 3 warp. The padding slots between
// sprites are loaded but never referenced; they leak ~1.7 KB // named sprites are loaded but never referenced; they leak a few
// at startup which is well below the cost of writing a // KB at startup which is well below the cost of writing a
// free-loop here (extra code in _ROOT eats the IIgs cluster // free-loop here (extra code in _ROOT eats the IIgs cluster
// budget more than the heap fragments hurt). // budget more than the heap fragments hurt).
memset(cels, 0, sizeof(cels)); memset(cels, 0, sizeof(cels));
// 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); count = jlSpriteBankLoad(ST_SPRITE_SHEET_PATH, cels, ST_SPRITE_SHEET_CELS, NULL);
}
if (count == 0u) { if (count == 0u) {
return false; 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);
// 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++) { for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i]; gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i];
} }
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) { for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
gRender.warpCels[i] = cels[ST_SPRITE_WARP_FIRST + 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++) { for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
gRender.passengerCels[i] = cels[ST_SPRITE_PASS_FIRST + i]; gRender.passengerCels[i] = cels[ST_SPRITE_PASS_FIRST + i];
} }
for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) { for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) {
gRender.flameCels[i] = cels[ST_SPRITE_FLAME_FIRST + 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));
}
// 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; return true;
} }
@ -1125,6 +1259,12 @@ static void destroySprites(void) {
gRender.taxiCels[i] = NULL; 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++) { for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
if (gRender.warpCels[i] != NULL) { if (gRender.warpCels[i] != NULL) {
jlSpriteDestroy(gRender.warpCels[i]); 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 // Build the ASCII -> (blockX | blockY << 8) lookup table used by
// jlDrawText. The font sheet was authored with each ASCII glyph at // jlDrawText. The font sheet was authored with each ASCII glyph at
// cell (ascii % 40, ascii / 40) (see assets/genPlaceholderArt.py), // cell (ascii % 40, ascii / 40) (see assets/genPlaceholderArt.py),