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
via `$CB02`. This is the **idle title state**.
`$4955` is the reverse: gate-off voice 1+2, noise off, load song
from `$6F75,X` (X = $716B current music index), set $5E9D=0,
JMP $5EC4. **Exit-to-title cleanup**.
`$4955` is the reverse: gate-off voice 1+2, noise off, then load a
disk file selected by `$6F75,X` (X = `$716B`) via the `$9936`
fast-loader, set $5E9D=0, JMP $5EC4. **Exit-to-title cleanup**.
CORRECTED 2026-07-20 (demo-subsystem trace): the `$6F75,X` table and
the `$9936` loader do NOT load music. `$6F75` holds a FILENAME DIGIT
patched into the reversed "S0:SPACETAXI 0_/N0S" string, and the four
"SPACETAXI 01..04" disk files are the **attract-demo recorded input
streams** (RLE `[joystick mask][tick count]` pairs; H/T/W/X screen
rotation, `$6F75` maps H=01, T=02, W=03, X=04; playback substitutes
the recorded mask for `$DC00` input via the `$48F2` hook). `$716B`
is the demo rotation index, not a music index. The music engine
proper ($CB00-$CFFF, per-voice streams, freq tables) is unrelated to
these files.
## Lives + death state
@ -955,7 +966,7 @@ check_note:
AND #$7F ; mask high bit (effect flag)
CMP #$61 ; note count = 97
BCC normal_note
JMP $CD56 ; effect command (out of scope for now)
JMP $CD56 ; effect command (traced -- see "Effect commands" below)
normal_note:
CLC; ADC $CB86,X ; transpose
@ -965,8 +976,8 @@ LDA $CEF1,Y -> $D400,X ; voice freq LO
LDA $CE90,Y -> $D401,X ; voice freq HI
LDA $CBBF,X -> $D405,X ; attack/decay
LDA $CBC0,X -> $D406,X ; sustain/release
LDA $CBC1,X -> $D403,X ; pulse-width lo
LDA $CBC2,X -> $D402,X ; pulse-width hi
LDA $CBC1,X -> $D403,X ; pulse-width HI ($D403 is the PW hi register)
LDA $CBC2,X -> $D402,X ; pulse-width LO
LDA $CBC4,X ; filter flag
PHP
LDA $D417; EOR #$FF; ORA $CBAB,X; EOR #$FF
@ -985,13 +996,85 @@ A=0; STA $CB83,X ; active flag = 0
BEQ continue
```
Frequency tables: `$CEF1-CF52` (97 entries hi), `$CE90-CEF0`
(97 entries lo). Note codes are 1..97 indexing both tables.
Frequency tables (CORRECTED 2026-07-20 against the trace above:
the earlier version of this line had lo/hi swapped): `$CEF1-CF51`
holds the 97 freq LO bytes (written to `$D400,X`), `$CE90-CEF0`
holds the 97 freq HI bytes (written to `$D401,X`). Note codes are
1..97 indexing both tables. Verified against raw.bin bytes:
`$CE90: 00 01 01 01 ...` (small = hi bytes of low notes),
`$CEF1: 00 0C 1C 2D 3F 51 ...` (lo bytes).
So the music is a simple **list-of-(note, optional-duration)** stream
per voice, with per-voice transpose and ADSR/filter state stored
adjacently. No subroutines, no jump tables -- just a flat stream
terminated by $00.
CORRECTED 2026-07-20 (full byte-level trace of `$CD0E-$CD55`): the
stream is **one byte per note event**, NOT (note, duration) pairs.
After the note plays, `$CD0E` re-reads the SAME byte at `($FC)` --
the pointer has not advanced yet -- and `BMI $CD24` tests the NOTE
byte's own bit 7 (the bit masked off by `AND #$7F` before the table
lookup):
- bit 7 CLEAR: default duration. `$CB84,X = $CB9B,X + $A2 + 1`
(next event = now + default duration + 1 jiffy), `$CB83,X = 0`.
- bit 7 SET: staccato/gap mode using `$CBC5,X`: if `$CBC5,X` is
negative it is first added to `$CB9B,X` (clamped at 0). The result
is stored BOTH into `$CB84,X` (+`$A2`+1, the note-on time) AND via
self-modifying code into the `SBC` immediate at `$CD3F`, and
`$CB83,X = $CB9B,X - 2 - (that value)`... i.e. `$CB83,X` receives
the GAP length. `$CB83,X` is therefore a gap counter, not a plain
flag: when the tick at `$CC74` finds it nonzero, it gates the voice
OFF and schedules the next (note-start) event at `now + $CB83,X`
(`CLC ADC $A2`). "0 = on" in the state table above really means
"no pending gap".
Then `$CD43` advances the track pointer by ONE byte (the note byte)
and refreshes zp `$FC/$FD`. Flat stream, terminated by `$00` (end of
track: gate off, pointer stays) or `$80` (end of song: pointer set to
`$FFFF`, gate off).
### Effect commands (`$CD56`) -- traced 2026-07-20, previously "out of scope"
A stream byte whose masked value `(byte & $7F) >= $61` is an effect.
`$CD56` re-reads the raw byte and transforms it with an
ASL/PHP/ASL/ASL/PLP/ROR/LSR/LSR dance that computes
`cmd = (bit7 << 5) | (byte & $1F)` -- so `$61-$7F` map to cmd
`$01-$1F` and `$E1-$FF` map to cmd `$21-$3F`. Dispatch (compare
chain, byte-exact; interpretive names are inference from the code
shapes, marked *):
| cmd range | bytes | action |
| --------- | ----- | ------ |
| `> $30` | 3 | skip two operand bytes (unused command class in this build) |
| `= $30` | 1+14 | *instrument load: copy the next 14 stream bytes into `$CBBF+`(voice window) -- ADSR, pulse width, waveform mask, filter state (`$CD71`, self-modified X operands) |
| `$20-$2F` | 1 | *set repeat counter: `$CBAA+Y = cmd & $0F` where `Y = $CB85,X + $CBD6` (`$CE84` slot indexer) |
| `$18` | 2 | set per-voice transpose `$CB86,X` from the next byte (`$CDB2`) |
| `$10-$17` (not `$18`) | 2 | set per-voice default duration `$CB9B,X` from the next byte (`$CDC3`) |
| `$0C-$0F` | 1 | decrement repeat counter (`SBC 1, AND $0F`) (`$CDD4`) |
| `$08-$0B` | 1 | *loop-back: condition via `$CE68` (cmd&3: 2 = if counter zero, 3 = if counter nonzero, else always); taken -> pop `$CB95/96,Y` saved pointer (`DEC $CB85,X` twice) and continue there; a negative slot index instead resets `$CB85,X` and ends the song (`$CDE8`) |
| `$04-$07` | 1 or 3 | *loop-start: condition false -> skip 2 operand bytes; true -> push `ptr+3` onto the `$CB95/96` pointer stack (`INC $CB85,X` twice) then fall into the jump below (`$CE17`) |
| `$01-$03` | 3 | *relative jump: add the 16-bit LE operand (bytes at ptr+1/+2) to the track pointer (`$CE4A`) |
After most commands control returns to `$CC99` to process the next
stream byte in the same tick (so command chains execute
"immediately", consuming no time).
### Song loading -- `$4955` is a patch stub, the real loader is `$9936`
`$4955` only silences the SID (`$D404 = 0`, `$D40B = 0`,
`$D412 = $80`), then: `LDX $716B` (music index), `LDA $6F75,X`,
`STA $99A7`, `JSR $9936`. **`$6F75` is not a pointer table -- it
holds a FILENAME CHARACTER** (observed bytes: index 7 -> `$31` '1',
19 -> `$32` '2', 22 -> `$33` '3', 23 -> `$34` '4'), and `$99A7` is a
character slot inside a REVERSED filename string at `$99A3+`
("S0N/_0 IXATECAPS:0S" = "S0:SPACETAXI 0_/N0S" reversed; a second
string at `$9990` is "SPACETAXI HI"). `$9936` is a self-modifying
IEC fast-loader (raw `$FFA8` CIOUT / `$FFAE` UNLSN serial traffic)
that pulls the song file off the disk. **The music DATA is not in
the resident program at all: each song ships as its own disk file**
-- the `SPACETAXI 01..04` / `SPACETAXI HI` PRGs (present in
`stuff/spacetaxi/extract/`). Whatever song was resident when the
raw.bin dump was taken is what sits in its load area there.
Timing base: `$A2` in the tick compare is the KERNAL jiffy clock low
byte, so all durations are in PAL jiffies (50 Hz) -- `$CB84,X` is a
"next event at this jiffy" target, not a countdown.
### Two reset variants

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

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 "spacetaxi.h"
#include "stDemoStreams.h"
// The C64 runs its physics + game logic once per GAME TICK, and a game
// tick is 2 video frames (the main loop waits on the raster twice per
// iteration, $5F94 + $5FA3) = ~30 Hz NTSC. The port renders at each
// platform's own frame rate (70 Hz DOS, 50 Hz ST/Amiga, ~15 fps IIgs),
// so gameplay is stepped on a fixed 33 ms accumulator rather than once
// per rendered frame. Without this the cab accelerates ~2.3x too fast
// on DOS and half-speed on the IIgs -- every per-tick constant (accel
// 14, gravity 1, the $7D8F templates, the demo RLE counts) is authored
// for this 30 Hz cadence, so only stepping at 30 Hz reproduces the
// original feel, identically on every port.
#define ST_GAME_TICK_MS 33u
// Cap catch-up ticks per rendered frame so a hitch (disk stall, cold
// cache) drops backlog instead of spiraling; ~165 ms simulated max.
#define ST_MAX_CATCHUP_TICKS 5u
static void applyInput(StGameT *game);
static void demoEnter(StGameT *game);
static void demoExitToTitle(StGameT *game);
static void demoTick(StGameT *game);
static void demoTickInput(StGameT *game);
static bool loadLevelByIndex(StGameT *game, uint8_t idx);
static void playingTick(StGameT *game);
// All 24 canonical Space Taxi levels (A..X), extracted from the C64
// ROM via stuff/spacetaxi/romToLevel.py. level01 = scene 0 = "A",
// level24 = scene 23 = "X".
static const char *gLevelPaths[] = {
"DATA/levels/level01.dat", "DATA/levels/level02.dat",
"DATA/levels/level03.dat", "DATA/levels/level04.dat",
"DATA/levels/level05.dat", "DATA/levels/level06.dat",
"DATA/levels/level07.dat", "DATA/levels/level08.dat",
"DATA/levels/level09.dat", "DATA/levels/level10.dat",
"DATA/levels/level11.dat", "DATA/levels/level12.dat",
"DATA/levels/level13.dat", "DATA/levels/level14.dat",
"DATA/levels/level15.dat", "DATA/levels/level16.dat",
"DATA/levels/level17.dat", "DATA/levels/level18.dat",
"DATA/levels/level19.dat", "DATA/levels/level20.dat",
"DATA/levels/level21.dat", "DATA/levels/level22.dat",
"DATA/levels/level23.dat", "DATA/levels/level24.dat",
"levels/level01.dat", "levels/level02.dat",
"levels/level03.dat", "levels/level04.dat",
"levels/level05.dat", "levels/level06.dat",
"levels/level07.dat", "levels/level08.dat",
"levels/level09.dat", "levels/level10.dat",
"levels/level11.dat", "levels/level12.dat",
"levels/level13.dat", "levels/level14.dat",
"levels/level15.dat", "levels/level16.dat",
"levels/level17.dat", "levels/level18.dat",
"levels/level19.dat", "levels/level20.dat",
"levels/level21.dat", "levels/level22.dat",
"levels/level23.dat", "levels/level24.dat",
};
#define ST_LEVEL_COUNT (sizeof(gLevelPaths) / sizeof(gLevelPaths[0]))
// Attract-demo rotation cursor (mirrors $5143). Playback is stepped by
// the main loop's fixed game-tick accumulator, so no wall-clock here.
static uint8_t gDemoRotation;
// Enter (or advance to the next screen of) the rolling attract demo:
// pick the rotation slot, load its level, seed the recorded stream
// ($6262 seeds mask/timer from the head), zero the demo score.
static void demoEnter(StGameT *game) {
const StDemoEntryT *e = &kDemoRotation[gDemoRotation & 3u];
gDemoRotation++;
if (!loadLevelByIndex(game, e->levelIndex)) {
demoExitToTitle(game);
return;
}
stEngineReset(game);
game->score = 0u;
game->demoStream = e->stream;
game->demoLen = e->length;
game->demoMask = e->stream[0];
game->demoTimer = e->stream[1];
game->demoOff = 2u;
game->demoEnded = false;
game->state = ST_STATE_DEMO;
}
static void demoExitToTitle(StGameT *game) {
if (stLevelLoad(&game->level, "levels/title.dat")) {
stRenderLevelChanged();
}
stRenderTitleReset(); // replay the intro -> attract cycle
stAudioSfxThrust(false);
game->state = ST_STATE_TITLE;
}
// Recorded-input playback, substituting for applyInput ($48F2): DEC
// the pair timer each game tick; at zero take the next [mask][count]
// pair. Mask bits 0-3 = UP/DOWN/LEFT/RIGHT (bit 7 is always set in
// the recordings, no fire bit -- the demo cab never lowers its gear,
// which is why every recorded ride ends in a crash).
static void demoTickInput(StGameT *game) {
StTaxiT *t = &game->taxi;
uint8_t mask;
bool up;
bool down;
bool left;
bool right;
// Advance the RLE stream by exactly one game tick (the caller runs
// this once per fixed 33 ms step, in lockstep with stEngineTick).
if (game->demoTimer > 0u) {
game->demoTimer--;
}
if (game->demoTimer == 0u) {
if ((uint16_t)(game->demoOff + 1u) < game->demoLen) {
game->demoMask = game->demoStream[game->demoOff];
game->demoTimer = game->demoStream[game->demoOff + 1u];
game->demoOff = (uint16_t)(game->demoOff + 2u);
} else {
// The C64 has no end-of-stream check (every recorded ride
// crashes first); hold idle until then.
game->demoMask = 0x80u;
game->demoTimer = 0xFFu;
}
}
mask = game->demoMask;
up = (mask & 0x01u) != 0u;
down = (mask & 0x02u) != 0u;
left = (mask & 0x04u) != 0u;
right = (mask & 0x08u) != 0u;
t->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0));
t->thrusting = (up || down || left || right);
t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) |
(left ? 4u : 0u) | (right ? 8u : 0u));
if (left) {
t->facing = ST_DIR_LEFT;
} else if (right) {
t->facing = ST_DIR_RIGHT;
}
}
// One fixed game-tick of the attract demo: recorded input -> physics ->
// passenger. The main loop runs this in lockstep with the 30 Hz clock.
static void demoTick(StGameT *game) {
demoTickInput(game);
stEngineTick(game);
stPassengerTick(game);
}
// One fixed game-tick of PLAYING: warp recede, or input -> physics ->
// passenger, with the land-SFX edge and the transporter-exit trigger.
// The thrust SFX is left to the caller (once per rendered frame).
static void playingTick(StGameT *game) {
bool wasLanded;
if (game->taxi.warpFrame > 0u) {
if (stEngineWarpTick(game)) {
stPassengerTransporterExit(game);
game->state = ST_STATE_LEVEL_DONE;
}
return;
}
wasLanded = game->taxi.landed;
applyInput(game);
stEngineTick(game);
stPassengerTick(game);
if (!wasLanded && game->taxi.landed) {
stAudioSfxLand();
}
// Flying up through the top-wall transporter exits the screen:
// begin the shrink-warp (delivers a carried "UP PLEASE" fare and
// advances the screen when it ends).
if (game->taxi.crashTicks == 0u && stEngineInTransporter(game)) {
stEngineStartWarp(game);
}
}
static void applyInput(StGameT *game) {
StTaxiT *t = &game->taxi;
int8_t jx;
@ -66,15 +211,36 @@ static void applyInput(StGameT *game) {
left = jlKeyDown(KEY_LEFT) || jx < -32;
right = jlKeyDown(KEY_RIGHT) || jx > 32;
// C64 Space Taxi gameplay only acts on the 4 directional bits of
// $DC00; the fire button is masked out except on menu/title
// screens. (Confirmed via disassembly at $6040-$60D6 in mem0801:
// the main input handler only loads X/Y velocity templates when
// L/R or U/D are held, fire is never tested in this path.)
// Menu fire-to-advance is handled by main()'s state switch.
// FIRE is the LANDING GEAR ($63DD): a press EDGE while airborne
// toggles the gear with an SFX; pressing UP while parked on a pad
// is the takeoff, which retracts the gear ($65AD AND #$FE). (The
// old comment here claiming "fire is never tested in this path"
// was wrong -- $63F1-$6401 tests it every tick.)
{
bool fire = jlKeyDown(KEY_SPACE) || jlJoyDown(JOYSTICK_0, JOY_BUTTON_0);
if (fire && !t->fireHeld && !t->landed) {
t->gearDown = !t->gearDown;
stAudioSfxGear();
}
t->fireHeld = fire;
}
if (up && t->landed) {
t->gearDown = false; // takeoff retracts the gear
}
// Gear down strips lateral thrust BEFORE the direction mask is
// built ($6190 AND #$13 runs upstream of the $60D6 mask store),
// so the flame table never sees L/R with the gear down either.
if (t->gearDown) {
left = false;
right = false;
}
t->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0));
t->thrusting = (up || down || left || right);
// $716A-style mask for the flame cel table (1=UP 2=DOWN 4=LEFT
// 8=RIGHT, same encoding kFlameCelByDirMask indexes).
t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) |
(left ? 4u : 0u) | (right ? 8u : 0u));
// Facing is purely cosmetic (sprite cel selection). Update when
// horizontal thrust is commanded; keep last facing otherwise so
@ -103,14 +269,26 @@ static bool loadLevelByIndex(StGameT *game, uint8_t idx) {
int main(void) {
jlConfigT config;
jlSurfaceT *stage;
StGameT game;
// game embeds a full StLevelT (tilemap + colormap = ~2 KB). As a
// stack local it held ~2 KB across the whole program, overflowing the
// IIgs (llvm-mos) soft stack once the deep asset-load call chain ran
// (stRenderInit -> loadSpriteSheet -> jlSpriteBankLoad -> fopen) and
// silently crashing back to GS/OS. It is a single program-lifetime
// instance, so static (BSS) is both correct and stack-friendly.
static StGameT game;
// Sprite codegen arena: after Phase 11 (shared-walker rewrite of
// the planar sprite path), sprites no longer consume arena bytes
// -- the per-cel work happens inside halSpriteDrawPlanes /
// halSpriteSavePlanes / halSpriteRestorePlanes as static lib code.
// 32 KB is plenty for whatever other codegen needs are around.
config.codegenBytes = 32UL * 1024;
// Sprite codegen: the 26 drawn cels (taxi/warp/passenger/flame,
// 24x24 = 3x3-tile) each compile to per-shift save/draw/restore asm.
// Compiling them is what makes sprite blitting fast (the interpreter
// costs ~200 ms/frame on the IIgs 65816). The arena is capped at one
// 64 KB bank (codegenArena attrNoCross); 60 KB fits all 26 cels with
// headroom. The old 32 KB was sized for procedural sprites only.
config.codegenBytes = 60UL * 1024;
config.audioBytes = 32UL * 1024; // music + SFX
if (!jlInit(&config)) {
@ -145,7 +323,7 @@ int main(void) {
// capture. Falls through to a black field if the asset is
// missing.
jlLogF("spacetaxi: stLevelLoad title.dat ...");
if (stLevelLoad(&game.level, "DATA/levels/title.dat")) {
if (stLevelLoad(&game.level, "levels/title.dat")) {
jlLogF("spacetaxi: title loaded, tilebank=%u",
(unsigned)game.level.tileBankId);
stRenderLevelChanged();
@ -154,12 +332,39 @@ int main(void) {
}
jlLogFlush();
// Fixed-timestep gameplay clock. gTickAccumMs banks real elapsed
// time; each frame consumes it in whole 30 Hz game ticks so physics
// advance at the C64's rate regardless of the render frame rate.
uint32_t gPrevTickMs = jlMillisElapsed();
uint32_t gTickAccumMs = 0u;
for (;;) {
uint8_t pendingTicks;
uint8_t tick;
jlInputPoll();
if (jlKeyPressed(KEY_ESCAPE)) {
break;
}
{
uint32_t nowMs = jlMillisElapsed();
uint32_t want;
gTickAccumMs += (uint32_t)(nowMs - gPrevTickMs);
gPrevTickMs = nowMs;
want = gTickAccumMs / ST_GAME_TICK_MS; // wide: no cast wrap
if (want > ST_MAX_CATCHUP_TICKS) {
// A long stall (level load, disk seek): resync to now
// and skip the gap instead of spiraling. Zero the whole
// accumulator so no backlog survives the truncated cast.
want = ST_MAX_CATCHUP_TICKS;
gTickAccumMs = 0u;
} else {
gTickAccumMs -= want * ST_GAME_TICK_MS; // keep sub-tick remainder
}
pendingTicks = (uint8_t)want; // guaranteed <= 5
}
switch (game.state) {
case ST_STATE_TITLE:
{
@ -185,7 +390,56 @@ int main(void) {
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
game.fareTarget = 1u; // C64 default ($48AF sets 1)
game.state = ST_STATE_OPTIONS;
break;
}
// Advance the sparkle -> walk -> takeoff intro (and the
// logo flip) on the fixed 30 Hz clock, so pre-game
// timing is identical on every port.
for (tick = 0u;
tick < pendingTicks && game.state == ST_STATE_TITLE;
tick++) {
stRenderTitleAdvance();
}
// Intro finished (cab took off): roll the attract
// demo, exactly like the C64's post-takeoff flow.
if (stRenderTitleIntroDone()) {
demoEnter(&game);
}
break;
}
case ST_STATE_DEMO:
{
// Any real input exits the attract loop ($5006 tests
// $DC00 != $7F).
int8_t jx = jlJoystickX(JOYSTICK_0);
int8_t jy = jlJoystickY(JOYSTICK_0);
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) ||
jlKeyDown(KEY_UP) || jlKeyDown(KEY_DOWN) ||
jlKeyDown(KEY_LEFT) || jlKeyDown(KEY_RIGHT) ||
jx < -32 || jx > 32 || jy < -32 || jy > 32) {
demoExitToTitle(&game);
break;
}
if (game.demoEnded) {
// The recorded ride crashed out; $6B57 skips the
// life bookkeeping in demo mode -- back to title.
demoExitToTitle(&game);
break;
}
if (game.score != 0u) {
// Fare delivered: advance the rotation ($501D ->
// $50C6) without visiting the title.
demoEnter(&game);
break;
}
for (tick = 0u;
tick < pendingTicks && game.state == ST_STATE_DEMO &&
!game.demoEnded && game.score == 0u;
tick++) {
demoTick(&game);
}
stAudioSfxThrust(game.taxi.thrusting);
break;
}
case ST_STATE_HIGHSCORES:
@ -196,6 +450,7 @@ int main(void) {
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
stRenderLevelChanged();
stRenderTitleReset();
game.state = ST_STATE_TITLE;
}
break;
@ -244,36 +499,18 @@ int main(void) {
break;
}
case ST_STATE_PLAYING:
{
bool wasLanded;
// Transporter shrink-warp in progress: suspend input and
// physics, just advance the recede animation; when it
// finishes, deliver any "UP PLEASE" fare and change screens.
if (game.taxi.warpFrame > 0u) {
if (stEngineWarpTick(&game)) {
stPassengerTransporterExit(&game);
game.state = ST_STATE_LEVEL_DONE;
// Advance physics at the fixed 30 Hz cadence -- each
// playingTick handles the warp recede, physics, passenger,
// land SFX, and transporter-exit trigger. Stop early if a
// tick changed state (crash game-over, level done).
for (tick = 0u;
tick < pendingTicks && game.state == ST_STATE_PLAYING;
tick++) {
playingTick(&game);
}
break;
}
wasLanded = game.taxi.landed;
applyInput(&game);
stEngineTick(&game);
stPassengerTick(&game);
// Edge-trigger SFX from state deltas.
// Continuous thrust SFX reflects the final tick's state.
stAudioSfxThrust(game.taxi.thrusting);
if (!wasLanded && game.taxi.landed) {
stAudioSfxLand();
}
// Flying up through the top-wall transporter exits the
// screen: begin the shrink-warp (which delivers a carried
// "UP PLEASE" fare and advances the screen when it ends).
if (game.taxi.crashTicks == 0u &&
stEngineInTransporter(&game)) {
stEngineStartWarp(&game);
}
break;
}
case ST_STATE_LEVEL_DONE:
// No music to stop -- gameplay is silent. The C64 plays
// a score-screen jingle (song 7 / song 6) between levels;
@ -283,9 +520,10 @@ int main(void) {
game.state = ST_STATE_PLAYING;
} else {
// ran out of levels -- back to title (player wins)
if (stLevelLoad(&game.level, "DATA/levels/title.dat")) {
if (stLevelLoad(&game.level, "levels/title.dat")) {
stRenderLevelChanged();
}
stRenderTitleReset();
game.state = ST_STATE_TITLE;
}
break;
@ -294,9 +532,10 @@ int main(void) {
// Restore the title tilemap so the press-start
// overlay isn't sitting on top of the last-played
// level's art.
if (stLevelLoad(&game.level, "DATA/levels/title.dat")) {
if (stLevelLoad(&game.level, "levels/title.dat")) {
stRenderLevelChanged();
}
stRenderTitleReset();
game.state = ST_STATE_TITLE;
}
break;
@ -305,9 +544,13 @@ int main(void) {
break;
}
// Silence continuous thrust SFX whenever we're not actively
// playing (title, level-intro, game-over, level-done).
if (game.state != ST_STATE_PLAYING) {
// Silence continuous thrust SFX whenever no cab is flying
// under thrust (title, menus, game-over, level-done). The
// attract DEMO is a recorded gameplay session and thrusts
// audibly, so it keeps its own per-tick thrust SFX -- without
// this exclusion the blanket silence overrode it and the demo
// cab flamed in total silence.
if (game.state != ST_STATE_PLAYING && game.state != ST_STATE_DEMO) {
stAudioSfxThrust(false);
}

View file

@ -20,8 +20,11 @@
// Ensoniq DOC channels (IIgs). The dispatch lives in
// stAudio.c with one entry point per voice command.
//
// input Joystick port 2 conventionally on the C64; we map
// thrust to fire and direction to all four cardinals.
// input Joystick port 2 conventionally on the C64: direction =
// the four cardinals (thrust), FIRE = the LANDING GEAR
// toggle while airborne ($63DD: press edge EORs $7197
// bit 0 with an SFX; $6462 requires gear down to land;
// $6190 strips left/right thrust while gear is down).
// Keyboard fallback: arrows + space for hosts without
// joysticks.
//
@ -96,15 +99,10 @@
#define ST_SUBPIXEL_SHIFT 8
#define ST_SUBPIXEL (1 << ST_SUBPIXEL_SHIFT)
// Taxi thrust cel cycling. The sprite asset
// (assets/genPlaceholderArt.py) lays out 4 taxi cels:
// cel 0 = idle (no thrust)
// cel 1..3 = thrust-flame frames (cycled while input is held)
// thrustFrame on StTaxiT counts 0..(ST_THRUST_CEL_COUNT *
// ST_THRUST_CEL_TICKS - 1) while thrusting. Renderer divides by
// ST_THRUST_CEL_TICKS to pick cel 1, 2, or 3.
#define ST_THRUST_CEL_COUNT 3u
#define ST_THRUST_CEL_TICKS 2u
// Taxi cels are the real C64 art (extractSprites.py): the $C0/$C1
// right-facing and $DC/$DD left-facing pairs with the $6A95 per-frame
// low-bit flicker; the engine flame is the separate direction-indexed
// sprite-2 cel set. Selection lives in stRender.c.
typedef enum {
@ -112,6 +110,7 @@ typedef enum {
ST_STATE_HIGHSCORES, // UP on title -> "THE IMMORTAL CABBIES" ($4C1F)
ST_STATE_INSTRUCTIONS, // DOWN on title -> author/about screen ($556A)
ST_STATE_OPTIONS, // "GAME VARIATIONS" menu ($5295): pick cabbie count
ST_STATE_DEMO, // rolling attract demo (recorded input playback)
ST_STATE_PLAYING,
ST_STATE_LEVEL_DONE,
ST_STATE_GAME_OVER
@ -188,10 +187,19 @@ typedef struct {
int16_t vx; // velocity accumulator (sub-pixel/frame)
int16_t vy;
StFacingE facing;
uint8_t thrustFrame; // 0..1 parity bit driving cab-cel flicker
bool thrusting; // any directional input held this frame
int8_t thrustDx; // -1 left, 0 neutral, +1 right
int8_t thrustDy; // -1 up, 0 neutral, +1 down
// Held-direction bit mask, mirroring $716A's encoding (the index
// into the $6DB0 flame cel table): 1=UP 2=DOWN 4=LEFT 8=RIGHT.
// With gear down the left/right bits are stripped before this is
// built, exactly like $6190 AND #$13 feeding $60D6.
uint8_t dirMask;
// Landing gear ($7197 bit 0). Toggled by a FIRE press edge while
// airborne, retracted on takeoff ($65B0), REQUIRED down to land
// ($6462) -- gear-up contact with a pad is a crash.
bool gearDown;
bool fireHeld; // previous-frame fire state (edge detect)
bool landed; // sitting on a pad
uint8_t onPad; // pad index if landed (0xFF if none)
// Death-animation countdown. >0 means crashed -- engine keeps the
@ -200,6 +208,9 @@ typedef struct {
// branch"). Reaches 0 -> respawn (or game-over). Mirrors the
// C64's $6B24/$6B4C phase-2/3 timing.
uint8_t crashTicks;
// Crash phase split ($6A72 fall vs $6B2A impact walk): false while
// the debris tumbles, true once it hits the floor.
bool crashImpacted;
// Transporter shrink-warp progress (0 = not warping; 1..64 = the
// takeoff/shrink animation $5C91/$5CB9). During the warp the taxi
// recedes up through the opening; the screen advances when it ends.
@ -207,15 +218,40 @@ typedef struct {
} StTaxiT;
// Passenger lifecycle phases (see the field comments in StPassengerT
// for the C64 sequences each one mirrors).
typedef enum {
ST_PASS_BEAM_IN = 0, // materialize at standX ($CB..$C7)
ST_PASS_WAIT, // stand + wave ($66D6 cycle)
ST_PASS_WALK_TO_CAB, // walk to the captured taxi X
ST_PASS_BOARD_OUT, // shrink into the cab ($C7..$CB)
ST_PASS_RIDING, // aboard, invisible
ST_PASS_DROP_IN, // delivery: materialize beside the cab
ST_PASS_WALK_TO_PAD, // walk to the destination standX
ST_PASS_DROP_OUT // shrink out -- fare complete
} StPassPhaseE;
typedef struct {
bool active;
bool onboard; // in the cab (true) or waiting at pad (false)
uint8_t currentPad; // where they are if waiting
uint8_t destPad;
int16_t x; // pixel position (for waiting/walking)
int16_t x; // pixel position
int16_t y;
uint8_t walkPhase; // animation cel (0..3)
uint8_t walkDir; // 0 = walking right, 1 = walking left
// Passenger lifecycle, per the traced sequences: materialize
// ($CB..$C7 grow-in), wave in place ($66D6 cycle -- he NEVER
// walks while waiting), walk to the landed cab ($6811 captures
// the taxi X once; $C4/$C5 leftward, $C2/$C3 rightward, +/-2 px
// per 3-tick step), shrink into the cab ($C7..$CB), ride, and
// the delivery mirror (materialize at the cab, walk to standX,
// shrink out).
StPassPhaseE phase;
int16_t targetX; // walk destination (captured once)
// Step counter within the current phase; in ST_PASS_WAIT it is
// the 0..3 wave-cycle index, in the walk phases its low bit is
// the stride cel alternation.
uint8_t waitPhase;
} StPassengerT;
@ -227,6 +263,15 @@ typedef struct {
uint32_t score;
uint8_t lives;
uint8_t levelIndex;
// Attract-demo playback state (ST_STATE_DEMO): the recorded RLE
// input stream and its cursor, plus the crash-finished signal the
// engine raises instead of decrementing lives ($6B57).
const uint8_t *demoStream;
uint16_t demoLen;
uint16_t demoOff;
uint8_t demoTimer;
uint8_t demoMask;
bool demoEnded;
// Player-selectable fare quota (1..4). Mirrors C64 $7213 (the
// GAME-VARIATIONS "cabbies" count), set on the title screen via
// LEFT/RIGHT joystick ($5310-$5328). This is the GLOBAL number of
@ -249,6 +294,9 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game);
// after stLevelLoad even when the StLevelT pointer is unchanged --
// stRenderLevel's dirty-cache compares pointers, not contents.
void stRenderLevelChanged(void);
void stRenderTitleAdvance(void);
bool stRenderTitleIntroDone(void);
void stRenderTitleReset(void);
// Draw an ASCII string into the stage at tile coords (bx, by) using
// the loaded font asset. No-op if the font asset failed to load.
@ -278,6 +326,7 @@ void stAudioFrameTick(void); // call once per host frame; counts down SFX
void stAudioPlayMusic(uint8_t musicId);
void stAudioStopMusic(void);
void stAudioSfxThrust(bool on);
void stAudioSfxGear(void);
void stAudioSfxLand(void);
void stAudioSfxPickup(void);
void stAudioSfxDropoff(void);

View file

@ -34,6 +34,8 @@
#define ST_SFX_DROPOFF_TICKS 25u
#define ST_SFX_LAND_HZ 90u
#define ST_SFX_LAND_TICKS 12u
#define ST_SFX_GEAR_HZ 140u // approximation -- $6410 table untraced
#define ST_SFX_GEAR_TICKS 5u
// C64-matched SFX behavior (see MECHANICS.md "Sound (SID)"):
//
@ -191,6 +193,15 @@ void stAudioSfxThrust(bool on) {
}
void stAudioSfxGear(void) {
// $63FC-$640C: the gear toggle fires SFX ptr $6410 through $42E9.
// The exact $6410 waveform is untraced; a short low click stands
// in until that SFX table is decoded -- marked approximation, not
// ROM-derived.
armSfx(ST_SFX_GEAR_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_GEAR_TICKS);
}
void stAudioSfxLand(void) {
armSfx(ST_SFX_LAND_HZ, ST_SFX_DEFAULT_ATTEN, ST_SFX_LAND_TICKS);
}

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_H_PX 24
// Death-animation duration. Matches the C64 sequence at $6A72/$6B24/
// $6B4C: phase-1 keeps integrating Y velocity until the cab falls to
// the floor (row >= $DA = 218), then phase-2 walks the sprite cels
// $CC..$D1 with a rate that slows each step, then phase-3 holds for
// 70 frames before the finalize (DEC lives, blank sprite). 120 host
// frames here covers the full "fall + sit" sequence at the port's
// physics scale. The cab keeps falling under gravity during the
// countdown so the visual matches the original "watch it drop".
#define ST_CRASH_ANIM_FRAMES 120u
// Death-animation duration, in GAME TICKS (the death countdown runs
// inside stEngineTick, now stepped at the fixed 30 Hz cadence). Matches
// the C64 sequence at $6A72/$6B24/$6B4C: phase-1 integrates Y velocity
// until the cab falls to the floor (row >= $DA), phase-2 walks cels
// $CC..$D1 at a slowing rate, phase-3 holds ~70 video frames (~35 game
// ticks). 60 game ticks (~2 s) comfortably covers fall + impact walk +
// hold. The cab keeps falling under gravity during the countdown so the
// visual matches the original "watch it drop".
#define ST_CRASH_ANIM_FRAMES 60u
// Edge reflection at the visible playfield edges. The C64's $6AED
// thresholds (col 23 left / col 65 in X-MSB high half) are in VIC
@ -164,6 +164,11 @@ static void respawnTaxi(StGameT *game) {
t->landed = false;
t->onPad = 0xFFu;
t->thrusting = false;
// $6892 forces #$C0 on every respawn: gear up, facing right.
t->gearDown = false;
t->fireHeld = false;
t->facing = ST_DIR_RIGHT;
t->crashImpacted = false;
// Boot any in-flight passenger out of the cab; a respawn doesn't
// keep the fare. Waiting passengers (still on a pad) survive.
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
@ -226,11 +231,21 @@ void stEngineTick(StGameT *game) {
if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; }
t->y += t->vy;
if (t->y > maxY) {
// Floor hit: the C64's phase-1 -> phase-2 transition (row
// $DA, $6ACC) -- the debris stops falling and the impact
// cel walk plays out where it landed.
t->y = maxY;
t->vy = 0;
t->crashImpacted = true;
}
t->crashTicks--;
if (t->crashTicks == 0u) {
if (game->state == ST_STATE_DEMO) {
// $6B57-$6B5E: a demo crash skips the life bookkeeping
// entirely; the main loop returns to the title.
game->demoEnded = true;
return;
}
if (game->lives > 0u) {
game->lives--;
}
@ -256,19 +271,6 @@ void stEngineTick(StGameT *game) {
t->vx = (int16_t)(t->vx + ax + (int16_t)L->xGrav);
t->vy = (int16_t)(t->vy + ay + (int16_t)L->yGrav);
if (t->thrusting) {
// Thrust-flame cel cycling. The asset (genPlaceholderArt.py)
// authors 3 thrust-flame variants at cels 1..3. Advance the
// counter every frame; cel selection in stRender divides by
// ST_THRUST_CEL_TICKS so the visible animation runs at ~10 Hz.
t->thrustFrame++;
if (t->thrustFrame >= (uint8_t)(ST_THRUST_CEL_COUNT * ST_THRUST_CEL_TICKS)) {
t->thrustFrame = 0u;
}
} else {
t->thrustFrame = 0u;
}
// Safety clamp on the velocity accumulator -- without this a long
// fall under gravity (or sustained one-way thrust against no
// collision) lets vx/vy wrap int16_t and reverse sign.
@ -314,8 +316,12 @@ void stEngineTick(StGameT *game) {
// crashes into it -- this is the "land gently or die"
// mechanic. In the port's 8-bit sub-pixel scale that high-
// byte-zero test is vy < ST_SUBPIXEL (256).
// $6462 additionally requires the GEAR DOWN: with the gear
// up the pad detector aborts and the pad surface is just
// lethal background -- fast descent OR gear-up = crash.
bool slowDescent = (t->vy < ST_SUBPIXEL);
if (slowDescent && onLandingPad(L, centerX, feetY, &landingPad)) {
if (slowDescent && t->gearDown &&
onLandingPad(L, centerX, feetY, &landingPad)) {
ny = (int32_t)(feetY / ST_TILE_PIXELS) * ST_TILE_PIXELS * ST_SUBPIXEL
- (int32_t)ST_TAXI_H_PX * ST_SUBPIXEL;
t->vx = 0;
@ -335,6 +341,7 @@ void stEngineTick(StGameT *game) {
t->vy = ST_CRASH_FALL_VY;
t->thrusting = false;
t->crashTicks = ST_CRASH_ANIM_FRAMES;
t->crashImpacted = false;
}
} else {
t->landed = false;
@ -382,10 +389,20 @@ bool stEngineInTransporter(const StGameT *game) {
// Transporter shrink-warp ($5C91 takeoff + $5CB9 pad2pad). The C64 walks
// the cab sprite through shrink cels $E2..$E9 (8 cels / 7 steps) while
// nudging it +1px right / -1px up per step, with a cadence that slows as
// it recedes: 4 frames/step for the first steps, 16 frames/step for the
// last. Step boundaries (cumulative frames): 4,8,12,16,32,48,64. Total 64
// frames (~1.1s). The port has no shrink cels, so the renderer scales a
// block by stEngineWarpStep instead.
// it recedes. $5CB9 INCs $5A66 and gates the step on (counter & mask):
// mask $03 (every 4) for the first steps, then $0F (every 16) from $E5.
//
// UNIT PROOF (from raw.bin, so this is not re-litigated): the animation
// runs in the loop at $5B85 -- JSR $5BBB (the animDispatcher, sole caller,
// which advances $5A66 via $5CB9) is called ONCE per iteration, while the
// single-frame raster wait $404B (LDA $D019 / AND #$01 / BEQ on line 252)
// runs TWICE per iteration ($5BAC + $5BB2), exactly like the gameplay
// loop's $5F94/$5FA3. So $5A66 advances once per 2 video frames = one
// GAME TICK. The boundaries 4,8,12,16,32,48,64 and the total 64 are
// therefore GAME TICKS: 64 ticks = 128 video frames = ~2.13 s NTSC. The
// values stay in their native C64 units (stEngineWarpTick runs once per
// game tick), unlike the port-invented crashTicks. The port has no shrink
// cels, so the renderer scales a block by stEngineWarpStep instead.
#define ST_WARP_TOTAL_FRAMES 64u
static const uint8_t kWarpStepFrame[8] = { 0u, 4u, 8u, 12u, 16u, 32u, 48u, 64u };
@ -410,7 +427,10 @@ void stEngineStartWarp(StGameT *game) {
game->taxi.vx = 0;
game->taxi.vy = 0;
game->taxi.thrusting = false;
stAudioSfxCrash(); // the C64 sets voice-3 noise ($81) on warp start
// $5CB0: the C64 gates SID voice-3 noise on ($D412 = $81) for the
// warp. Voice 3's freq word is $0617 (set at the $4092 RNG init,
// ~92 Hz shift rate) -- the darkest rumble, portable pitch 31.
jlAudioNoise(31u, 4u);
}
@ -429,6 +449,7 @@ bool stEngineWarpTick(StGameT *game) {
}
if (t->warpFrame >= (uint8_t)ST_WARP_TOTAL_FRAMES) {
t->warpFrame = 0u;
jlAudioNoise(0u, 15u); // warp done: rumble off
return true;
}
return false;

View file

@ -64,6 +64,10 @@ void stHudDraw(jlSurfaceT *stage, const StGameT *game) {
if (!p->active) {
continue;
}
if (p->phase == ST_PASS_DROP_IN || p->phase == ST_PASS_WALK_TO_PAD ||
p->phase == ST_PASS_DROP_OUT) {
break; // delivery in progress: no hail, no dest
}
if (!p->onboard) {
snprintf(buf, sizeof(buf), "HEY TAXI!");
} else if (p->destPad == ST_DEST_TRANSPORTER) {

View file

@ -11,6 +11,7 @@
#include <stdlib.h>
#include <string.h>
#include "joey/file.h"
#include "spacetaxi.h"
@ -47,7 +48,8 @@ bool stLevelLoad(StLevelT *out, const char *path) {
memset(out, 0, sizeof(*out));
fp = fopen(path, "rb");
// path is a name relative to DATA/ (e.g. "levels/title.dat").
fp = jlDataOpen(path, "rb");
if (fp == NULL) {
return false;
}

View file

@ -27,11 +27,13 @@
#include "spacetaxi.h"
#define ST_PASSENGER_W_PX 16
#define ST_PASSENGER_H_PX 16
#define ST_PASSENGER_H_TILES (ST_PASSENGER_H_PX / ST_TILE_PIXELS)
#define ST_WALK_FRAMES 4u
#define ST_WALK_TICKS_PER_CEL 6u
// The waiting passenger's in-place wave (and every beam/walk step)
// advances every 3 game ticks -- the C64's $715E == 3 reload. Since
// stPassengerTick is now called once per fixed game tick (~30 Hz),
// this is a plain tick counter, in lockstep with the physics.
#define ST_WAVE_STEP_TICKS 3u
// Verified via emulator trace of $4354 (BCD-add) with the $43B9 blob:
// a basic delivered fare adds '5' at the ones digit of the DDDD.DD HUD
@ -46,7 +48,7 @@
static uint32_t gRng;
static uint8_t gWalkTick;
static uint8_t gWaveTicks; // game ticks since the last wave/step advance
// Frames until the next fare is forced to spawn (mirrors gDeathStage0Rng).
static int16_t gSpawnCountdown;
// Pad deliveries completed on the current screen. Destination pads are
@ -56,12 +58,22 @@ static int16_t gSpawnCountdown;
static uint8_t gDeliveredThisScreen;
static bool arrivedAtTarget(const StPassengerT *p);
static void deliverFare(StGameT *game, StPassengerT *p);
static void pickDestination(StGameT *game, StPassengerT *p);
static void placeOnPad(StPassengerT *p, const StLevelT *level, uint8_t padIdx);
static uint8_t rngPad(uint8_t n);
static void spawnPassenger(StGameT *game);
static void walkPassenger(StGameT *game, StPassengerT *p);
static void walkStride(StPassengerT *p);
// Walk arrival test. The C64 aligns the stride with AND #$FE and
// compares ((x ^ target) & ~1) == 0; the port's +/-2 window is the
// same idea, robust to an odd captured taxi X.
static bool arrivedAtTarget(const StPassengerT *p) {
int16_t d = (int16_t)(p->x - p->targetX);
return d >= -2 && d <= 2;
}
static void deliverFare(StGameT *game, StPassengerT *p) {
@ -117,6 +129,18 @@ static uint8_t rngPad(uint8_t n) {
}
// One +/-2 px walk stride ($6D66 rightward, $6D68 leftward), with
// the stride cel alternation on waitPhase's low bit.
static void walkStride(StPassengerT *p) {
if (p->targetX > p->x) {
p->x = (int16_t)(p->x + 2);
} else if (p->targetX < p->x) {
p->x = (int16_t)(p->x - 2);
}
p->waitPhase ^= 1u;
}
static void spawnPassenger(StGameT *game) {
StPassengerT *p = &game->passengers[0];
uint8_t padIdx;
@ -128,51 +152,17 @@ static void spawnPassenger(StGameT *game) {
p->active = true;
p->onboard = false;
p->walkPhase = 0u;
p->walkDir = 0u;
p->phase = ST_PASS_BEAM_IN;
p->waitPhase = 0u;
p->destPad = 0u;
placeOnPad(p, &game->level, padIdx);
}
// Idle back-and-forth walk across the pad while waiting to be picked up.
// The C64 passenger AI wasn't fully traced; this gives each waiting fare
// some motion instead of standing still.
static void walkPassenger(StGameT *game, StPassengerT *p) {
const StPadT *pad;
int16_t padXMin;
int16_t padXMax;
p->walkPhase = (uint8_t)((p->walkPhase + 1u) % ST_WALK_FRAMES);
if (p->currentPad >= game->level.padCount) {
return;
}
pad = &game->level.pads[p->currentPad];
padXMin = (int16_t)(pad->tileX * ST_TILE_PIXELS);
padXMax = (int16_t)((pad->tileX + pad->tileW) * ST_TILE_PIXELS - ST_PASSENGER_W_PX);
if (padXMax <= padXMin) {
return; // pad too narrow to walk on
}
if (p->walkDir == 0u) {
p->x++;
if (p->x >= padXMax) {
p->x = padXMax;
p->walkDir = 1u;
}
} else {
p->x--;
if (p->x <= padXMin) {
p->x = padXMin;
p->walkDir = 0u;
}
}
}
void stPassengerReset(StGameT *game) {
uint8_t i;
gWalkTick = 0u;
gWaveTicks = 0u;
gDeliveredThisScreen = 0u;
gSpawnCountdown = ST_SPAWN_CAP_FRAMES;
// Deterministic per-screen seed: varies the pad sequence level to
@ -206,26 +196,113 @@ void stPassengerTick(StGameT *game) {
return;
}
if (!p->onboard) {
// Walk in place while waiting.
if (++gWalkTick >= ST_WALK_TICKS_PER_CEL) {
gWalkTick = 0u;
walkPassenger(game, p);
// One shared step clock (the C64's 3-game-tick cadence, $715E == 3)
// drives every phase: beams, wave, and walk strides. Called once
// per fixed game tick, so this is a plain tick count.
{
bool stepDue = (++gWaveTicks >= ST_WAVE_STEP_TICKS);
if (stepDue) {
gWaveTicks = 0u;
}
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) {
// $6811 captures the taxi X ONCE at landing; the
// walk aims there even if the cab lifts off.
p->targetX = (int16_t)(t->x >> ST_SUBPIXEL_SHIFT);
p->phase = ST_PASS_WALK_TO_CAB;
p->waitPhase = 0u;
}
break;
case ST_PASS_WALK_TO_CAB:
if (stepDue) {
walkStride(p);
if (arrivedAtTarget(p)) {
if (t->landed && t->onPad == p->currentPad) {
p->phase = ST_PASS_BOARD_OUT;
p->waitPhase = 0u;
} else {
// Cab left early: walk home and wait again.
// (Not byte-traced -- the C64 cab cannot
// normally leave mid-walk; this just keeps
// the state machine sane if ours does.)
p->targetX = (int16_t)game->level.pads[p->currentPad].standX;
p->phase = ST_PASS_WALK_TO_PAD;
p->waitPhase = 0u;
}
}
}
break;
case ST_PASS_BOARD_OUT:
// Shrink into the cab: cels $C7..$CB (5 steps).
if (stepDue && ++p->waitPhase >= 5u) {
p->onboard = true;
p->phase = ST_PASS_RIDING;
p->waitPhase = 0u;
pickDestination(game, p);
stAudioSfxPickup();
}
} else if (p->destPad != ST_DEST_TRANSPORTER) {
// Deliver by landing on the destination pad. (UP-PLEASE fares are
// delivered on the transporter fly-up; see stPassengerTransporterExit.)
if (t->landed &&
break;
case ST_PASS_RIDING:
// Deliver by landing on the destination pad. (UP-PLEASE
// fares deliver on the transporter fly-up instead; see
// stPassengerTransporterExit.)
if (p->destPad != ST_DEST_TRANSPORTER &&
t->landed &&
t->onPad < game->level.padCount &&
t->onPad == p->destPad) {
p->onboard = false;
p->phase = ST_PASS_DROP_IN;
p->waitPhase = 0u;
p->x = (int16_t)(t->x >> ST_SUBPIXEL_SHIFT);
p->targetX = (int16_t)game->level.pads[p->destPad].standX;
p->currentPad = p->destPad;
p->y = (int16_t)((game->level.pads[p->destPad].tileY -
ST_PASSENGER_H_TILES) * ST_TILE_PIXELS);
}
break;
case ST_PASS_DROP_IN:
// Materialize beside the cab: cels $CB..$C7.
if (stepDue && ++p->waitPhase >= 5u) {
p->phase = ST_PASS_WALK_TO_PAD;
p->waitPhase = 0u;
}
break;
case ST_PASS_WALK_TO_PAD:
if (stepDue) {
walkStride(p);
if (arrivedAtTarget(p)) {
p->phase = ST_PASS_DROP_OUT;
p->waitPhase = 0u;
}
}
break;
case ST_PASS_DROP_OUT:
// Shrink out at standX -- fare complete.
if (stepDue && ++p->waitPhase >= 5u) {
deliverFare(game, p);
}
break;
}
}
}

View file

@ -33,9 +33,10 @@
// DOS 8.3 filename limit: "tilebank0.tbk" is 9.3, fopen fails under
// DOSBox strict 8.3. Shortened to "tbank%u.tbk" (6.3) so all four
// targets can use the same filenames without per-platform aliasing.
#define ST_TILE_BANK_PATH_FMT "DATA/tiles/tbank%u.tbk"
#define ST_SPRITE_SHEET_PATH "DATA/sprites/sprites.spr"
#define ST_FONT_PATH "DATA/font.tbk"
#define ST_TILE_BANK_PATH_FMT "tiles/tbank%u.tbk"
#define ST_SPRITE_SHEET_PATH "sprites/sprites.spr"
#define ST_SPRITE_SHEET_SPC "sprites/sprites.spc"
#define ST_FONT_PATH "font.tbk"
#define ST_TILE_BANK_MAX 256u
@ -52,29 +53,33 @@
// cels), row 2 is the flame (8 cels). Cells lay out left-to-right
// top-to-bottom in the .spr blob's cellCount = 27.
#define ST_SPRITE_SHEET_COLS 9
#define ST_SPRITE_SHEET_CELS 27
#define ST_SPRITE_SHEET_CELS 55 /* 11 cols x 5 rows: taxi, passenger, flame, warp, death */
#define ST_SPRITE_TAXI_FIRST 0
#define ST_SPRITE_PASS_FIRST 9
#define ST_SPRITE_FLAME_FIRST 18
#define ST_SPRITE_PASS_FIRST 11
#define ST_SPRITE_FLAME_FIRST 22
// Taxi sprite: 24x24 px = 3 tiles wide x 3 tiles tall. The port's
// sprite asset (genPlaceholderArt.py) authors 4 cels:
// cel 0: idle cab (no thrust)
// cel 1: thrust frame A
// cel 2: thrust frame B
// cel 3: thrust frame C
// Index: thrusting ? (1 + thrustFrame/ST_THRUST_CEL_TICKS) : 0. The
// C64's $619B selects between LEFT-facing ($DC) and RIGHT-facing
// ($C0) cab bases with a 1-bit flicker on each, but the port's asset
// doesn't carry left/right variants -- left/right facing isn't
// visually reflected until the sheet adds them.
// sprite asset (extractSprites.py, real C64 art from raw.bin) lays
// out sheet row 0 in the live sprite-pointer order:
// cel 0 = $C1 (right, gear DOWN) cel 1 = $C0 (right, gear up)
// cel 2 = $DC (left, gear up) cel 3 = $DD (left, gear DOWN)
// $619B selects the base pair by held direction (LEFT -> $DC pair,
// RIGHT -> $C0 pair, otherwise keep the last facing) and preserves
// bit 0 = the LANDING GEAR state (fire-toggled, $63DD). The in-
// flight body NEVER animates -- the cel is a static function of
// (facing, gearDown):
// cel = facingLeft ? (2 + gearDown) : (1 - gearDown)
// (The $6A95 EOR that an earlier port version modeled as a flicker
// is the crash-debris animation, $CC<->$CD during the fall.)
#define ST_TAXI_W_PX 24
#define ST_TAXI_H_PX 24
#define ST_TAXI_CEL_COUNT 4
// Transporter shrink-warp cels: the cab receding, in the free row-0 slots
// right after the 4 drive cels. The C64 walks 8 shrink cels ($E2..$E9);
// the port approximates with 5 sizes and maps the 8 warp steps onto them.
#define ST_SPRITE_WARP_FIRST (ST_SPRITE_TAXI_FIRST + ST_TAXI_CEL_COUNT)
#define ST_WARP_CEL_COUNT 5
// Transporter shrink-warp cels (sheet row 3): the SEVEN drawn cels
// $E2..$E8 of the $5CB9 chain, identity-mapped to warp steps 0..6.
// $5CDA ends the warp when the pointer REACHES $E9 and hides the
// sprite ($718E = 0) -- $E9 is never displayed, so step 7 draws
// nothing at all.
#define ST_SPRITE_WARP_FIRST 33
#define ST_WARP_CEL_COUNT 7
// Flame placeholder fallback (only used if real sprite-2 cel is
// missing): a small bright rectangle below the cab while thrusting.
@ -88,10 +93,16 @@
// Real flame sprite cels (24x24, from raw.bin sprite ptrs in $6DB0
// table). Indexed by direction-mask -> cel via kFlameCelByDirMask
// below. $6D6A's parity bit ($716C) flickers the flame off every
// other frame.
// other GAME TICK (2 video frames, so a 12.5Hz PAL strobe that fuses
// on a CRT). The port reproduces the LOOK with a wall-clock virtual
// strobe (flameStrobeOn): at high frame rates it yields the authentic
// shimmer; a rendered frame long enough to span a whole strobe phase
// (low-fps IIgs) shows the flame continuously instead of a 7.5Hz
// blink that reads as "no flame".
#define ST_FLAME_CEL_W_PX 24
#define ST_FLAME_CEL_H_PX 24
#define ST_FLAME_CEL_COUNT 8
#define ST_FLAME_STROBE_MS 40u /* one $716C phase = 1 PAL game tick */
// Passenger: 24x24 (C64 hardware sprite size is 24x21; we pad 3 rows
// transparent at the bottom in the asset extraction to keep a square
@ -101,11 +112,28 @@
// $67A6 incrementing gSpr1PtrShadow from $C6 toward $CC.
#define ST_PASSENGER_W_PX 24
#define ST_PASSENGER_H_PX 24
#define ST_PASSENGER_CEL_COUNT 9
#define ST_PASSENGER_CEL_WALK0 0
#define ST_PASSENGER_CEL_WALK1 1
#define ST_PASSENGER_CEL_COUNT 11
#define ST_PASSENGER_CEL_WALK0 0 /* $C4 walk LEFT a */
#define ST_PASSENGER_CEL_WALK1 1 /* $C5 walk LEFT b */
#define ST_PASSENGER_CEL_BOARD0 2 /* maps to C64 ptr $C6 */
#define ST_PASSENGER_CEL_SPARKLE 8 /* C64 ptr $D9 -- 3rd sparkle cel */
#define ST_PASSENGER_CEL_WALKR0 9 /* $C2 walk RIGHT a */
#define ST_PASSENGER_CEL_WALKR1 10 /* $C3 walk RIGHT b */
// Crash-death cels (sheet row 4): $CC/$CD debris alternation during
// the fall, then the $CE..$D1 ground-impact walk, then hidden.
#define ST_DEATH_CEL_COUNT 6
#define ST_SPRITE_DEATH_FIRST 44
// Startup progress bar: 2 load steps (font, sprite bank) + the 36
// per-cel compiles (4 taxi + 11 passenger + 8 flame + 7 warp +
// 6 death).
#define ST_LOAD_STEPS_TOTAL 38
#define ST_LOAD_BAR_X 60
#define ST_LOAD_BAR_Y 110
#define ST_LOAD_BAR_H 6
#define ST_LOAD_BAR_COLOR 1u /* white fill */
#define ST_LOAD_BAR_FRAME_COLOR 6u /* blue frame, cab color */
// Library-wide worst case (16-px-group window on planar ports).
#define ST_SPRITE_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(ST_TAXI_W_PX / 8, ST_TAXI_H_PX / 8)
@ -118,6 +146,7 @@ typedef struct {
bool bankLoaded;
jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT];
jlSpriteT *warpCels[ST_WARP_CEL_COUNT];
jlSpriteT *deathCels[ST_DEATH_CEL_COUNT];
jlSpriteT *flameCels[ST_FLAME_CEL_COUNT];
jlSpriteBackupT flameBackup;
uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
@ -132,6 +161,10 @@ typedef struct {
uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
bool taxiHasBackup;
bool passengerHasBackup[ST_MAX_PASSENGERS];
uint32_t flamePrevMs; // flameStrobeOn frame-span tracker
uint32_t deathMs; // crash-anim step anchor
uint8_t deathStep; // impact-walk position (0..4)
bool deathActive; // crash anim state latched
// Tilemap repaint gating: the static playfield art only needs to
// be blitted to the stage once per scene change, not every frame.
// Per-frame full repaint blows the per-frame budget on emulated
@ -145,8 +178,11 @@ static StRenderStateT gRender;
static bool loadTileBank(uint8_t bankId);
static bool loadSpriteSheet(void);
static bool loadSpriteSheet(jlSurfaceT *stage);
static bool loadFontSheet(jlSurfaceT *stage);
static void loadProgress(jlSurfaceT *stage, uint8_t step);
static bool flameStrobeOn(void);
static int16_t passCelForPtr(uint8_t ptr);
static void buildAsciiMap(void);
static void destroySprites(void);
@ -172,26 +208,11 @@ static void destroySprites(void);
// parks all hardware sprite slots at (col $AA, row $8C) so they don't
// flash garbage when the title is first shown.
// Recorded (input_mask, frame_duration) pairs at C64 $0902. Played by
// $48F2 once the title intro reaches the demo phase.
static const uint8_t kTitleDemoScript[256] = {
0x80, 0x17, 0x88, 0x0A, 0x80, 0x0A, 0x84, 0x04, 0x85, 0x03, 0x81, 0x03, 0x80, 0x0A, 0x88, 0x04,
0x80, 0x01, 0x81, 0x0E, 0x80, 0x0D, 0x82, 0x02, 0x80, 0x0A, 0x81, 0x09, 0x85, 0x07, 0x84, 0x05,
0x80, 0x02, 0x88, 0x05, 0x8A, 0x04, 0x82, 0x03, 0x80, 0x05, 0x85, 0x10, 0x81, 0x08, 0x89, 0x02,
0x88, 0x0E, 0x8A, 0x03, 0x88, 0x06, 0x81, 0x07, 0x80, 0x12, 0x82, 0x05, 0x80, 0x04, 0x81, 0x11,
0x80, 0x04, 0x82, 0x04, 0x86, 0x09, 0x84, 0x02, 0x95, 0x07, 0x85, 0x04, 0x81, 0x01, 0x80, 0x43,
0x81, 0x0A, 0x84, 0x05, 0x86, 0x03, 0x82, 0x06, 0x80, 0x07, 0x81, 0x09, 0x80, 0x04, 0x88, 0x0B,
0x80, 0x09, 0x84, 0x04, 0x80, 0x09, 0x81, 0x01, 0x89, 0x04, 0x80, 0x08, 0x85, 0x04, 0x81, 0x01,
0x80, 0x05, 0x81, 0x15, 0x80, 0x0C, 0x82, 0x02, 0x86, 0x07, 0x80, 0x05, 0x81, 0x09, 0x80, 0x13,
0x84, 0x01, 0x85, 0x03, 0x80, 0x06, 0x88, 0x0B, 0x80, 0x16, 0x81, 0x0A, 0x80, 0x01, 0x88, 0x04,
0x80, 0x0C, 0x81, 0x03, 0x85, 0x02, 0x84, 0x03, 0x85, 0x0C, 0x81, 0x01, 0x80, 0x0D, 0x82, 0x07,
0x80, 0x06, 0x81, 0x02, 0x89, 0x0C, 0x88, 0x02, 0x80, 0x05, 0x82, 0x04, 0x80, 0x0A, 0x81, 0x04,
0x85, 0x01, 0x84, 0x04, 0x80, 0x0D, 0x81, 0x05, 0x89, 0x0E, 0x81, 0x01, 0x80, 0x11, 0x84, 0x17,
0x80, 0x05, 0x81, 0x04, 0x80, 0x19, 0x81, 0x03, 0x89, 0x01, 0x88, 0x0E, 0x80, 0x07, 0x84, 0x05,
0x80, 0x0C, 0x81, 0x0D, 0x80, 0x19, 0x88, 0x01, 0x80, 0x02, 0x88, 0x04, 0x80, 0x1B, 0x88, 0x0A,
0x89, 0x0B, 0x81, 0x14, 0x80, 0x02, 0x82, 0x03, 0x80, 0x05, 0x84, 0x03, 0x85, 0x09, 0x84, 0x05,
0x85, 0x05, 0x84, 0x02, 0x86, 0x08, 0x84, 0x01, 0x80, 0x01, 0x81, 0x03, 0x89, 0x12, 0x81, 0x08,
};
// (The fabricated title "demo physics" stage and its truncated copy
// of demo stream 01 were deleted 2026-07-21: the real C64 never flies
// the cab around the title after takeoff -- it enters the ATTRACT
// DEMO, now a proper game state (ST_STATE_DEMO) driven by the full
// recorded input streams in stDemoStreams.h.)
// C64 reference values straight from the asm at $4A03 dispatch +
// titleSpriteSetup ($4525) + sprite-init tables ($4994/$49AC).
@ -199,7 +220,7 @@ static const uint8_t kTitleDemoScript[256] = {
#define ST_TITLE_STAGE_WALK 3u // $4A24
#define ST_TITLE_STAGE_HANDOFF 4u // $4A45 -> $67A6
#define ST_TITLE_STAGE_LIFTOFF 5u // $4A48
#define ST_TITLE_STAGE_DEMO 6u // intro over; demo physics
#define ST_TITLE_STAGE_DONE 6u // intro over; attract demo takes over
#define ST_TITLE_SPARKLE_FRAMES 0x5Au // $47CC LDA #$5A STA $473F
#define ST_TITLE_WALK_TICK_RELOAD 3u // $715E observed in raw.bin
// Walk target = gPadHoverXCol ($715F) = $28 set at $47D8.
@ -220,7 +241,16 @@ static const uint8_t kTitleDemoScript[256] = {
// $66D6 sparkle cel table -- cycled by $66B7 during stage 2.
// Each entry shown for $715E (= 3) frames; one full cycle = 12 frames.
static const uint8_t kTitleSparkleCels[4] = { 0xC6, 0xC7, 0xD9, 0xC7 };
// The $66D6 wave/sparkle cycle {$C6,$C7,$D9,$C7}: the title sparkle
// AND the waiting passenger's in-place wave both step through it (the
// gameplay passenger never moves -- he stands at standX and waves).
static const uint8_t kPassengerWaveCels[4] = { 0xC6, 0xC7, 0xD9, 0xC7 };
// Crash impact walk cadence ($6B2A: reload starts at 2 ticks and
// INCs each step -- 2,3,4,5 ticks for cels $CE..$D1 at the NTSC
// 33 ms game tick), then the sprite hides.
static const uint16_t kDeathStepMs[4] = { 66u, 100u, 133u, 166u };
#define ST_DEATH_TUMBLE_MS 66u /* $CC/$CD swap every 2 game ticks */
// Map direction-mask (CIA1 PortA bits after EOR #$FF: bit0=UP bit1=DOWN
// bit2=LEFT bit3=RIGHT) to a flameCels[] index, mirroring the C64
@ -267,22 +297,16 @@ typedef struct {
bool cabVisible;
bool flameVisible; // sprite 2 from $6D6A flameSpriteUpdate
uint8_t flameDirMask; // mirrors $716A passed into $6DB0,X
uint8_t flameParity; // mirrors $716C; flickers flame off when 0
// Demo (post-intro stage 6) playback state -- script from $0902.
uint16_t scriptIdx;
uint8_t scriptTimer;
int32_t demoX; // ST_SUBPIXEL fixed sub-pixel x
int32_t demoY;
int16_t demoVx;
int16_t demoVy;
// Logo flip-book + color cycle. The "SPACE TAXI" logo is 103 cells
// of char $84; the C64 ($4827) copies one of the flip frames
// $85..$88 over char $84's bitmap every 4th frame, walking the
// ping-pong table $48A7 = {1,2,3,4,3,2} so each dot rotates end over
// end, and advances the 8-color cycle $489C when the index hits 4.
uint8_t logoFlipDiv; // mirrors $48A6 (mod-4 frame divider)
uint8_t logoFlipDiv; // mirrors $48A6 (mod-4 tick divider)
uint8_t logoFlipIdx; // mirrors $48A5 (0..5 ping-pong index)
uint8_t logoColorIdx; // mirrors $48A4 (0..7 color cycle index)
bool logoNeedsPaint; // flip/color changed (or tilemap repainted)
} StTitleStateT;
// The single animated logo character. Its bitmap is flip-booked through
@ -355,14 +379,45 @@ void stRenderInit(jlSurfaceT *stage) {
jlPaletteSet(stage, 0u, kDefaultPalette);
jlScbSetRange(stage, 0u, (uint16_t)(SURFACE_HEIGHT - 1u), 0u);
// Tile bank is loaded on demand in stRenderLevel (per-level).
if (!loadSpriteSheet()) {
jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH);
}
// Startup feedback: the IIgs spends several seconds streaming the
// banks and compiling sprite cels to their asm routines, all of
// which used to happen against a black screen. Show a growing bar
// immediately; once the font is in, add the LOADING caption. The
// font loads FIRST because text needs its glyph surface.
jlSurfaceClear(stage, 0u);
loadProgress(stage, 0u);
if (!loadFontSheet(stage)) {
jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH);
}
buildAsciiMap();
loadProgress(stage, 1u);
// Tile bank is loaded on demand in stRenderLevel (per-level).
if (!loadSpriteSheet(stage)) {
jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH);
}
titleReset();
}
bool stRenderTitleIntroDone(void) {
return gTitle.stage == ST_TITLE_STAGE_DONE;
}
// Advance the title intro by ONE game tick. The main loop calls this
// on the fixed 30 Hz accumulator (not once per rendered frame) so the
// sparkle / walk / takeoff sequence and the logo flip run at the C64's
// rate on every port -- identical pre-game timing everywhere.
void stRenderTitleAdvance(void) {
titleTick();
}
// Restart the title intro (sparkle -> walk -> takeoff). Called on
// every return to the title so the attract cycle replays -- the C64
// re-enters the intro the same way after a demo exits.
void stRenderTitleReset(void) {
titleReset();
}
@ -478,14 +533,12 @@ static void titleReset(void) {
gTitle.cabVisible = true;
gTitle.passengerVisible = true;
gTitle.flameVisible = false;
// Demo state: initial script pos / pair-0 duration preload.
gTitle.scriptIdx = 0u;
gTitle.scriptTimer = kTitleDemoScript[1];
gTitle.demoX = (int32_t)ST_TITLE_CAB_SX_INIT * ST_SUBPIXEL;
gTitle.demoY = (int32_t)ST_TITLE_CAB_SY_INIT * ST_SUBPIXEL;
// Force the first frame to be an animation step (3 -> 0 on first ++)
// so the logo paints its first flip frame + color immediately.
// Force the first tick to be an animation step (3 -> 0 on first ++)
// AND force a logo paint on the first render frame so the flip
// frame + color appear immediately (the tilemap was just repainted
// by stRenderLevelChanged, erasing any prior logo cells).
gTitle.logoFlipDiv = 3u;
gTitle.logoNeedsPaint = true;
}
@ -498,7 +551,32 @@ static void titleReset(void) {
// color cycle $489C = {red,orange,yellow,green,blue,lt-blue,cyan,purple}
// (C64 codes, which are this port's palette indices). We reproduce it by
// re-pasting every $84 cell with the current flip-frame tile + color.
static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) {
// Flip/color advance ($4827 divider + $484D/$4853 index+color). Runs
// once per GAME TICK (called from titleTick), so the flip-book steps
// every 4 game ticks -- the C64's $48A6 mod-4 gate at the 30 Hz main-
// loop rate, frame-rate-independent. Sets logoNeedsPaint when it steps;
// the actual re-paste is deferred to titlePaintLogo in the render pass.
static void titleAdvanceLogo(void) {
gTitle.logoFlipDiv = (uint8_t)((gTitle.logoFlipDiv + 1u) & 3u);
if (gTitle.logoFlipDiv != 0u) {
return;
}
gTitle.logoFlipIdx++;
if (gTitle.logoFlipIdx == 6u) {
gTitle.logoFlipIdx = 0u;
}
if (gTitle.logoFlipIdx == 4u) {
gTitle.logoColorIdx = (uint8_t)((gTitle.logoColorIdx + 1u) & 7u);
}
gTitle.logoNeedsPaint = true;
}
// Re-paste every $84 logo cell with the current flip-frame tile + color
// ($489C palette codes = the port's indices). Render-pass only, and only
// when the flip advanced or the tilemap was just repainted -- between
// steps the logo cells sit static on the tilemap.
static void titlePaintLogo(jlSurfaceT *stage, const StLevelT *level) {
static const uint8_t kFlip[6] = { 1u, 2u, 3u, 4u, 3u, 2u }; // $48A7
static const uint8_t kColor[8] = { 0x02u, 0x08u, 0x07u, 0x05u,
0x06u, 0x0Eu, 0x03u, 0x04u }; // $489C
@ -509,14 +587,11 @@ static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) {
uint8_t by;
size_t i;
// $4827: only step the flip-book every 4th frame.
gTitle.logoFlipDiv = (uint8_t)((gTitle.logoFlipDiv + 1u) & 3u);
if (gTitle.logoFlipDiv != 0u) {
if (!gTitle.logoNeedsPaint) {
return;
}
gTitle.logoNeedsPaint = false;
// Paint the CURRENT flip frame + color (C64 displays table[$48A5]
// this frame, then advances), then repaint every $84 logo cell.
ch = (uint8_t)(ST_LOGO_CHAR + kFlip[gTitle.logoFlipIdx]);
fg = kColor[gTitle.logoColorIdx];
tile = gRender.tileValid[ch] ? &gRender.tiles[ch] : NULL;
@ -530,16 +605,6 @@ static void titleAnimateLogo(jlSurfaceT *stage, const StLevelT *level) {
}
}
}
// $484D INC $48A5 + $4853 gate: wrap the ping-pong index at 6, and
// advance the color cycle when it reaches 4.
gTitle.logoFlipIdx++;
if (gTitle.logoFlipIdx == 6u) {
gTitle.logoFlipIdx = 0u;
}
if (gTitle.logoFlipIdx == 4u) {
gTitle.logoColorIdx = (uint8_t)((gTitle.logoColorIdx + 1u) & 7u);
}
}
@ -577,6 +642,11 @@ static void titleStepPassenger(void) {
// $4A03 dispatcher. One iteration per host frame, exactly as the C64
// runs $4A03 from $47E4 each iteration of the title intro loop.
static void titleTick(void) {
// Logo flip-book + color cycle advance once per game tick (its own
// mod-4 divider gates the actual step), independent of the intro
// stage machine below.
titleAdvanceLogo();
switch (gTitle.stage) {
case ST_TITLE_STAGE_SPARKLE:
// $4A17: JSR $66B7 / DEC $473F / BEQ -> INC $7163.
@ -600,7 +670,7 @@ static void titleTick(void) {
if (gTitle.decayTimer == 0u) {
gTitle.decayTimer = gTitle.decayReload;
gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u);
gTitle.passengerPtr = kTitleSparkleCels[gTitle.sparkleIdx];
gTitle.passengerPtr = kPassengerWaveCels[gTitle.sparkleIdx];
}
if (gTitle.introFrameCount > 0u) {
gTitle.introFrameCount--;
@ -652,90 +722,23 @@ static void titleTick(void) {
gTitle.cabPtr = 0xC0;
gTitle.flameDirMask = 0x01; // UP only
gTitle.flameVisible = true;
gTitle.flameParity ^= 1u; // $6D8B-$6D90 toggle on every call
if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) {
// $4A64-$4A6C: INC gDeathStage; silence voice 3.
gTitle.cabVisible = false;
gTitle.flameVisible = false;
gTitle.stage = ST_TITLE_STAGE_DEMO;
// Demo continues from wherever the cab is -- the C64 does
// NOT reset the cab position. Physics + gravity bring it
// back into view from the top.
gTitle.demoX = (int32_t)gTitle.cabSx * ST_SUBPIXEL;
gTitle.demoY = (int32_t)gTitle.cabSy * ST_SUBPIXEL;
gTitle.demoVx = 0;
gTitle.demoVy = (int16_t)(-2 * ST_SUBPIXEL); // takeoff momentum
// $4A64 INCs gDeathStage out of the intro: on the C64 the
// machine now enters the rolling attract demo. The port
// signals spacetaxi.c via stRenderTitleIntroDone().
gTitle.stage = ST_TITLE_STAGE_DONE;
}
break;
case ST_TITLE_STAGE_DEMO:
case ST_TITLE_STAGE_DONE:
default:
{
// Demo phase = physicsTick with $48F2 sourcing input from
// kTitleDemoScript. $48F2 advance pattern at $4903-$4923:
// DEC $4740; BNE return; -- still on current pair
// LDY #$03; LDA ($3F),Y; -- new timer from NEXT pair
// STA $4740;
// $3F += 2; -- advance to next pair
uint8_t mask;
int8_t dx;
int8_t dy;
int32_t maxX = (int32_t)(SURFACE_WIDTH - ST_TAXI_W_PX) * ST_SUBPIXEL;
int32_t maxY = (int32_t)(SURFACE_HEIGHT - ST_TAXI_H_PX) * ST_SUBPIXEL;
if (gTitle.scriptTimer > 0u) {
gTitle.scriptTimer--;
}
if (gTitle.scriptTimer == 0u) {
gTitle.scriptIdx = (uint16_t)((gTitle.scriptIdx + 2u) & 0xFFu);
gTitle.scriptTimer =
kTitleDemoScript[(gTitle.scriptIdx + 1u) & 0xFFu];
if (gTitle.scriptTimer == 0u) {
gTitle.scriptTimer = 1u;
}
}
mask = kTitleDemoScript[gTitle.scriptIdx & 0xFFu];
// CIA1 PortA bits (after EOR #$FF at $6043):
// bit0 = UP, bit1 = DOWN, bit2 = LEFT, bit3 = RIGHT, bit4 = FIRE.
dx = (int8_t)(((mask & 0x08u) ? 1 : 0) - ((mask & 0x04u) ? 1 : 0));
dy = (int8_t)(((mask & 0x02u) ? 1 : 0) - ((mask & 0x01u) ? 1 : 0));
// Level-A defaults for the demo: xAccel/yAccel = 14, yGrav = 1.
gTitle.demoVx = (int16_t)(gTitle.demoVx + dx * 14);
gTitle.demoVy = (int16_t)(gTitle.demoVy + dy * 14 + 1);
if (gTitle.demoVx > 512) gTitle.demoVx = 512;
if (gTitle.demoVx < -512) gTitle.demoVx = -512;
if (gTitle.demoVy > 512) gTitle.demoVy = 512;
if (gTitle.demoVy < -512) gTitle.demoVy = -512;
gTitle.demoX += gTitle.demoVx;
gTitle.demoY += gTitle.demoVy;
if (gTitle.demoX < 0) { gTitle.demoX = 0; gTitle.demoVx = 0; }
if (gTitle.demoX > maxX) { gTitle.demoX = maxX; gTitle.demoVx = 0; }
// Y can go negative (cab off-screen above) -- let it. Only
// clamp at the bottom.
if (gTitle.demoY > maxY) { gTitle.demoY = maxY; gTitle.demoVy = 0; }
gTitle.cabSx = (int16_t)(gTitle.demoX >> ST_SUBPIXEL_SHIFT);
gTitle.cabSy = (int16_t)(gTitle.demoY >> ST_SUBPIXEL_SHIFT);
// Only re-show the cab once it's back inside visible Y.
if (gTitle.cabSy >= (int16_t)ST_SPRITE_Y_OFFSET) {
gTitle.cabVisible = true;
}
// Flame: per $6D6A, on whenever any direction bit is set in the
// mask. Build the mask from dx/dy the same way the C64 reads
// CIA1 PortA: bit0=UP bit1=DOWN bit2=LEFT bit3=RIGHT.
{
uint8_t mask = 0u;
if (dy < 0) mask |= 0x01u; // UP
if (dy > 0) mask |= 0x02u; // DOWN
if (dx < 0) mask |= 0x04u; // LEFT
if (dx > 0) mask |= 0x08u; // RIGHT
gTitle.flameDirMask = mask;
gTitle.flameVisible = (mask != 0u);
if (gTitle.flameVisible) {
gTitle.flameParity ^= 1u;
}
}
// Intro finished -- the title art sits static (logo cycle
// continues); spacetaxi.c switches to ST_STATE_DEMO.
break;
}
}
}
@ -778,10 +781,12 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
// (e.g., transitioned from gameplay back to title) -- otherwise
// it's a fast no-op.
stRenderLevel(stage, &game->level);
// Flip-book + color-cycle the "SPACE TAXI" logo (char $84 cells)
// over the freshly-painted tilemap, BEFORE the demo sprites
// save-under and draw, so the sprites overlay the live logo.
titleAnimateLogo(stage, &game->level);
// Paint the "SPACE TAXI" logo (char $84 cells) at its current
// flip frame + color over the freshly-painted tilemap, BEFORE
// the sprites save-under and draw so they overlay it. The flip
// ADVANCE happens on the game-tick clock (titleAdvanceLogo);
// this only re-pastes when it changed or the tilemap repainted.
titlePaintLogo(stage, &game->level);
// No "fares per game" overlay on the title: the C64 has a
// separate options/menu scene driven by $5295 (header text)
// and $52E1 / $533C (the "1 2 3 4" digit row at row 2 col 28
@ -790,15 +795,15 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
// text -- only "BY JOHN F. BUTCHER" credits and the
// UP=hiscore / DOWN=instructions / FIRE=begin joystick line.
// Options-menu scene is a separate state to be built later.
// Run the C64 title intro state machine (sparkle -> passenger
// walk -> taxi takeoff -> scripted demo) and draw whichever
// sprites are visible this frame.
titleTick();
// The intro state machine (sparkle -> passenger walk -> taxi
// takeoff) is advanced on the fixed game-tick clock in the main
// loop (stRenderTitleAdvance), not here -- this pass only DRAWS
// whichever sprites the current intro state has visible.
// sprite-X -> visible col = sprite-X - 24 (C64 border offset).
// sprite-Y -> visible row = sprite-Y - 50 (top border).
// Draw lowest priority first: flame (sprite 2), then passenger
// (sprite 1), then cab (sprite 0) on top.
if (gTitle.flameVisible && (gTitle.flameParity & 1u) &&
if (gTitle.flameVisible && flameStrobeOn() &&
gTitle.flameDirMask < 16u) {
int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask];
if (cel >= 0 && gRender.flameCels[cel] != NULL) {
@ -812,15 +817,7 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
}
}
if (gTitle.passengerVisible) {
// Map live C64 sprite ptr -> sprite-sheet cel index.
// $C4..$CB -> cels 0..7 (walk-LEFT cels then boarding)
// $D9 -> cel 8 (3rd sparkle cel from $66D6)
int16_t cel;
if (gTitle.passengerPtr == 0xD9) {
cel = ST_PASSENGER_CEL_SPARKLE;
} else {
cel = (int16_t)gTitle.passengerPtr - 0xC4;
}
int16_t cel = passCelForPtr(gTitle.passengerPtr);
if (cel < 0) {
cel = 0;
}
@ -931,12 +928,19 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
}
// Restore prev-frame sprite backups FIRST so the static tilemap
// shows through where the taxi/passenger used to be. Then we'll
// save+draw at the new positions below.
// shows through where the taxi/passenger/flame used to be. Then
// we'll save+draw at the new positions below. LIFO order: the cab
// was saved AFTER the flame (its backup holds flame pixels), so
// the cab restores first and the flame restore then cleans the
// re-deposited flame pixels.
if (gRender.taxiHasBackup) {
jlSpriteRestoreUnder(stage, &gRender.taxiBackup);
gRender.taxiHasBackup = false;
}
if (gRender.flameHasBackup) {
jlSpriteRestoreUnder(stage, &gRender.flameBackup);
gRender.flameHasBackup = false;
}
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
if (gRender.passengerHasBackup[i]) {
jlSpriteRestoreUnder(stage, &gRender.passengerBackup[i]);
@ -952,43 +956,82 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
px = (int16_t)(game->taxi.x >> ST_SUBPIXEL_SHIFT);
py = (int16_t)(game->taxi.y >> ST_SUBPIXEL_SHIFT);
// Cel = idle (0) when not thrusting, otherwise cycle through the
// three authored flame frames (1, 2, 3). Falls back to cel 0 if
// a thrust cel isn't authored. During the crash anim ($6B24/$6B4C
// in the C64) the original walks sprite-0 through death cels
// $CC..$D1 at a slowing rate; the port doesn't have those cels
// so the cab keeps its normal sprite as it falls -- the falling
// motion + crash SFX + voice-1 scream are the cue.
if (game->taxi.thrusting) {
taxiCel = (uint8_t)(1u +
(game->taxi.thrustFrame / ST_THRUST_CEL_TICKS));
if (taxiCel >= ST_TAXI_CEL_COUNT) {
taxiCel = 0u;
}
// $619B cel select: facing picks the $C0/$C1 (right) or $DC/$DD
// (left) pair; bit 0 is the LANDING GEAR state. The body is a
// STATIC cel -- no flicker of any kind. Sheet order
// [$C1,$C0,$DC,$DD]:
// right: ptr = $C0|gear -> cel 1 - gear
// left: ptr = $DC|gear -> cel 2 + gear
// (The crash anim uses the dedicated death cels below.)
if (game->taxi.facing == ST_DIR_LEFT) {
taxiCel = (uint8_t)(game->taxi.gearDown ? 3u : 2u);
} else {
taxiCel = 0u;
taxiCel = (uint8_t)(game->taxi.gearDown ? 0u : 1u);
}
if (gRender.taxiCels[taxiCel] == NULL) {
taxiCel = 0u;
}
if (game->taxi.warpFrame > 0u) {
// Transporter shrink-warp: draw the cab-receding cel for this warp
// step (the C64 walks shrink cels $E2..$E9; the port has 5 sizes,
// one selected per step). Uses the taxi's save-under so it undraws
// cleanly next frame -- no tilemap repaint needed.
uint8_t step = stEngineWarpStep(game); /* 0..7 */
uint8_t wcel = (uint8_t)((uint16_t)step * ST_WARP_CEL_COUNT / 8u);
if (wcel >= ST_WARP_CEL_COUNT) {
wcel = (uint8_t)(ST_WARP_CEL_COUNT - 1u);
// Engine flame: C64 sprite 2 ($6D6A) at (cab_x - 2, cab_y),
// cel indexed by the $716A direction mask through the $6DB0
// table, drawn on alternate frames only (the parity flicker),
// hidden when no input is held. Drawn BEFORE the cab so the cab
// (sprite 0) keeps VIC priority on top.
if (game->taxi.warpFrame == 0u && game->taxi.thrusting &&
!game->taxi.landed && flameStrobeOn() &&
game->taxi.dirMask < 16u) {
int8_t fcel = kFlameCelByDirMask[game->taxi.dirMask];
if (fcel >= 0 && gRender.flameCels[fcel] != NULL) {
jlSpriteSaveAndDraw(stage, gRender.flameCels[fcel],
(int16_t)(px - 2), py,
&gRender.flameBackup);
gRender.flameHasBackup = true;
}
if (gRender.warpCels[wcel] != NULL) {
jlSpriteSaveAndDraw(stage, gRender.warpCels[wcel], px, py,
}
if (game->taxi.crashTicks > 0u) {
// Crash death: phase 1 tumbles the $CC/$CD debris pair while
// falling; the floor hit ($6ACC row $DA) starts the $CE..$D1
// impact walk at the slowing $6B2A cadence, then the wreck
// hides until respawn.
uint32_t nowMs = jlMillisElapsed();
int16_t dcel = -1;
if (!gRender.deathActive) {
gRender.deathActive = true;
gRender.deathMs = nowMs;
gRender.deathStep = 0u;
}
if (!game->taxi.crashImpacted) {
dcel = (int16_t)((nowMs / ST_DEATH_TUMBLE_MS) & 1u);
gRender.deathMs = nowMs; // re-anchor for the walk
gRender.deathStep = 0u;
} else {
if (gRender.deathStep < 4u &&
nowMs - gRender.deathMs >= kDeathStepMs[gRender.deathStep]) {
gRender.deathMs = nowMs;
gRender.deathStep++;
}
if (gRender.deathStep < 4u) {
dcel = (int16_t)(2u + gRender.deathStep);
}
}
if (dcel >= 0 && gRender.deathCels[dcel] != NULL) {
jlSpriteSaveAndDraw(stage, gRender.deathCels[dcel], px, py,
&gRender.taxiBackup);
gRender.taxiHasBackup = true;
}
} else if (game->taxi.warpFrame > 0u) {
// Transporter shrink-warp: identity map onto the SEVEN drawn
// chain cels $E2..$E8; step 7 (pointer reaching $E9) hides
// the sprite entirely, exactly like $5CDA/$718E.
uint8_t step = stEngineWarpStep(game); /* 0..7 */
if (step < ST_WARP_CEL_COUNT && gRender.warpCels[step] != NULL) {
jlSpriteSaveAndDraw(stage, gRender.warpCels[step], px, py,
&gRender.taxiBackup);
gRender.taxiHasBackup = true;
}
} else if (gRender.taxiCels[taxiCel] != NULL) {
// Save-under + draw. Backup is replayed at the start of next
// frame above to undraw cleanly.
gRender.deathActive = false;
jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py,
&gRender.taxiBackup);
gRender.taxiHasBackup = true;
@ -1000,12 +1043,6 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
jlFillRect(stage, px, py, ST_TAXI_W_PX, ST_TAXI_H_PX,
game->level.sprite0Color);
gRender.tilemapDirty = true;
// Flame placeholder (only when the cab sprite isn't authored,
// since cels 1..3 of the authored asset already include the
// flame baked in). C64 sprite 2 ($6D6A, writes $7177/$7199)
// is positioned at
// (taxi_col - 2, taxi_row), cel-indexed by $716A direction;
// we use sprite1Color ($D028) so per-level palette is honored.
if (game->taxi.thrusting) {
jlFillRect(stage,
(int16_t)(px + ST_FLAME_OFFSET_X_PX),
@ -1020,10 +1057,38 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
const StPassengerT *p = &game->passengers[i];
jlSpriteT *cel;
if (!p->active || p->onboard) {
continue; // onboard passengers ride invisibly inside the cab
int16_t pcel;
if (!p->active || p->phase == ST_PASS_RIDING) {
continue; // riding passengers are invisible in the cab
}
cel = gRender.passengerCels[p->walkPhase % ST_PASSENGER_CEL_COUNT];
// Cel by lifecycle phase (see StPassPhaseE): beams shrink or
// grow through $C7..$CB, the wait wave cycles $66D6, walks
// alternate the direction's stride pair.
switch (p->phase) {
case ST_PASS_BEAM_IN:
case ST_PASS_DROP_IN:
pcel = (int16_t)(7 - (p->waitPhase > 4u ? 4u : p->waitPhase));
break;
case ST_PASS_BOARD_OUT:
case ST_PASS_DROP_OUT:
pcel = (int16_t)(3 + (p->waitPhase > 4u ? 4u : p->waitPhase));
break;
case ST_PASS_WALK_TO_CAB:
case ST_PASS_WALK_TO_PAD:
if (p->targetX < p->x) {
pcel = (int16_t)((p->waitPhase & 1u) ? ST_PASSENGER_CEL_WALK1
: ST_PASSENGER_CEL_WALK0);
} else {
pcel = (int16_t)((p->waitPhase & 1u) ? ST_PASSENGER_CEL_WALKR1
: ST_PASSENGER_CEL_WALKR0);
}
break;
case ST_PASS_WAIT:
default:
pcel = passCelForPtr(kPassengerWaveCels[p->waitPhase & 3u]);
break;
}
cel = gRender.passengerCels[pcel];
if (cel == NULL) {
jlFillRect(stage, p->x, p->y,
ST_PASSENGER_W_PX, ST_PASSENGER_H_PX, 8u);
@ -1035,6 +1100,13 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
gRender.passengerHasBackup[i] = true;
}
if (game->state == ST_STATE_DEMO) {
// Attract banners ($45A7-$4614 text): drawn every frame over
// the live demo, before the present.
stRenderDrawText(stage, 7u, 3u, "DEMO, USE JOYSTICK TO EXIT");
stRenderDrawText(stage, 3u, 24u, "THIS IS 1 OF 25 DIFFERENT SCREENS!");
}
if (game->state == ST_STATE_LEVEL_DONE) {
int16_t midY = (int16_t)(10 * ST_TILE_PIXELS);
jlFillRect(stage, 0, midY, SURFACE_WIDTH,
@ -1082,37 +1154,99 @@ static bool loadTileBank(uint8_t bankId) {
}
static bool loadSpriteSheet(void) {
static bool loadSpriteSheet(jlSurfaceT *stage) {
jlSpriteT *cels[ST_SPRITE_SHEET_CELS];
uint16_t count;
uint16_t i;
uint8_t step;
bool precompiled;
// .spr blob carries all 27 cels (9 cols x 3 rows of 3x3-tile
// .spr blob carries all 36 cels (9 cols x 4 rows of 3x3-tile
// chunky 4bpp blobs) in PNG reading order: row 0 taxi, row 1
// passenger, row 2 flame. The padding slots between named
// sprites are loaded but never referenced; they leak ~1.7 KB
// at startup which is well below the cost of writing a
// passenger, row 2 flame, row 3 warp. The padding slots between
// named sprites are loaded but never referenced; they leak a few
// KB at startup which is well below the cost of writing a
// free-loop here (extra code in _ROOT eats the IIgs cluster
// budget more than the heap fragments hurt).
memset(cels, 0, sizeof(cels));
// Prefer the pre-compiled bank: its cels arrive already compiled (native
// routines dropped straight into the codegen arena), so the jlSpriteCompile
// loop below is a no-op fast path. Fall back to the portable .spr + runtime
// JIT if no .spc is staged for this build.
count = jlSpriteBankLoadPrecompiled(ST_SPRITE_SHEET_SPC, cels, ST_SPRITE_SHEET_CELS, NULL);
precompiled = (count != 0u);
if (count == 0u) {
count = jlSpriteBankLoad(ST_SPRITE_SHEET_PATH, cels, ST_SPRITE_SHEET_CELS, NULL);
}
if (count == 0u) {
return false;
}
jlLogF("stRender: sprite sheet loaded (%u cels)", (unsigned)count);
jlLogF("stRender: sprite sheet loaded (%u cels, %s)", (unsigned)count,
precompiled ? "PRECOMPILED" : "jit");
loadProgress(stage, 2u);
// Compile every drawn cel to its per-shift asm routine. Without
// this, jlSprite{Draw,SaveUnder,SaveAndDraw} fall to the per-pixel
// interpreter (sp->slot stays NULL) -- catastrophic on the IIgs
// 65816: the 3 title sprites alone cost ~200 ms/frame interpreted
// (~3.7 fps). Compiling routes them to the fast path (UBER's
// benchmarks prove codegen works on every port). jlSpriteCompile is
// a no-op fallback if the arena is exhausted (cel stays interpreted).
{
uint16_t compiled = 0u;
// Assign all cels first, then compile title-critical ones
// (taxi, passenger, flame) before the gameplay-only warp cels so
// a tight arena still fast-paths the title.
for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i];
}
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
gRender.warpCels[i] = cels[ST_SPRITE_WARP_FIRST + i];
}
for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) {
gRender.deathCels[i] = cels[ST_SPRITE_DEATH_FIRST + i];
}
for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
gRender.passengerCels[i] = cels[ST_SPRITE_PASS_FIRST + i];
}
for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) {
gRender.flameCels[i] = cels[ST_SPRITE_FLAME_FIRST + i];
}
step = 3u;
for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.taxiCels[i]) ? 1u : 0u));
loadProgress(stage, step++);
}
for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.passengerCels[i]) ? 1u : 0u));
loadProgress(stage, step++);
}
for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) {
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.flameCels[i]) ? 1u : 0u));
loadProgress(stage, step++);
}
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.warpCels[i]) ? 1u : 0u));
loadProgress(stage, step++);
}
for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) {
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.deathCels[i]) ? 1u : 0u));
loadProgress(stage, step++);
}
jlLogF("stRender: sprite cels compiled: %u/%u", (unsigned)compiled,
(unsigned)(ST_TAXI_CEL_COUNT + ST_PASSENGER_CEL_COUNT + ST_FLAME_CEL_COUNT + ST_WARP_CEL_COUNT + ST_DEATH_CEL_COUNT));
}
// First-run cache: having just JIT-compiled the bank, write it out as a
// .spc so the next launch loads the compiled routines and skips the JIT
// (ship the small .spr, cache the big per-platform compiled bank). Best-
// effort -- a read-only/write-protected disk just means we JIT again.
if (!precompiled) {
if (jlSpriteBankSavePrecompiled(ST_SPRITE_SHEET_SPC, cels, count, NULL)) {
jlLogF("stRender: cached compiled bank -> %s", ST_SPRITE_SHEET_SPC);
}
}
return true;
}
@ -1125,6 +1259,12 @@ static void destroySprites(void) {
gRender.taxiCels[i] = NULL;
}
}
for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) {
if (gRender.deathCels[i] != NULL) {
jlSpriteDestroy(gRender.deathCels[i]);
gRender.deathCels[i] = NULL;
}
}
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
if (gRender.warpCels[i] != NULL) {
jlSpriteDestroy(gRender.warpCels[i]);
@ -1172,6 +1312,70 @@ static bool loadFontSheet(jlSurfaceT *stage) {
}
// The C64 flame strobe ($716C: one game tick on, one off) mapped to
// wall-clock so every port shows the intended LOOK: at >=50fps this
// is the authentic 12.5Hz shimmer; when a rendered frame spans a
// whole strobe phase (low fps) the flame draws every frame -- the
// CRT-fusion appearance instead of an unreadable slow blink.
static bool flameStrobeOn(void) {
uint32_t nowMs = jlMillisElapsed();
bool phase = ((nowMs / ST_FLAME_STROBE_MS) & 1u) != 0u;
bool spans = (nowMs - gRender.flamePrevMs) >= ST_FLAME_STROBE_MS;
gRender.flamePrevMs = nowMs;
return phase || spans;
}
// Map a live C64 sprite-1 pointer value onto the sheet's passenger
// row (extractSprites.py pass_ptrs order):
// $C4..$CB -> cels 0..7 (walk cels, boarding shrink)
// $D9 -> cel 8 (the wave/sparkle cel)
// Single source of truth for the title path and the gameplay wave.
static int16_t passCelForPtr(uint8_t ptr) {
if (ptr == 0xD9u) {
return ST_PASSENGER_CEL_SPARKLE;
}
if (ptr == 0xC2u) {
return ST_PASSENGER_CEL_WALKR0;
}
if (ptr == 0xC3u) {
return ST_PASSENGER_CEL_WALKR1;
}
return (int16_t)ptr - 0xC4;
}
// Startup progress: outline drawn on step 0 so the screen shows life
// the moment stRenderInit runs, bar grows per completed step, LOADING
// caption appears once the font glyphs exist. Cheap enough to call
// per sprite compile even on the IIgs (present base ~3 ms).
static void loadProgress(jlSurfaceT *stage, uint8_t step) {
int16_t w;
if (step > (uint8_t)ST_LOAD_STEPS_TOTAL) {
step = (uint8_t)ST_LOAD_STEPS_TOTAL;
}
if (step == 0u) {
jlFillRect(stage, ST_LOAD_BAR_X - 2, ST_LOAD_BAR_Y - 2,
(uint16_t)(SURFACE_WIDTH - 2 * ST_LOAD_BAR_X + 4),
ST_LOAD_BAR_H + 4, ST_LOAD_BAR_FRAME_COLOR);
jlFillRect(stage, ST_LOAD_BAR_X - 1, ST_LOAD_BAR_Y - 1,
(uint16_t)(SURFACE_WIDTH - 2 * ST_LOAD_BAR_X + 2),
ST_LOAD_BAR_H + 2, 0u);
}
w = (int16_t)(((int32_t)step * (SURFACE_WIDTH - 2 * ST_LOAD_BAR_X)) / ST_LOAD_STEPS_TOTAL);
if (w > 0) {
jlFillRect(stage, ST_LOAD_BAR_X, ST_LOAD_BAR_Y, (uint16_t)w,
ST_LOAD_BAR_H, ST_LOAD_BAR_COLOR);
}
if (gRender.fontReady) {
stRenderDrawText(stage, 16u, 11u, "LOADING");
}
jlStagePresent();
}
// Build the ASCII -> (blockX | blockY << 8) lookup table used by
// jlDrawText. The font sheet was authored with each ASCII glyph at
// cell (ascii % 40, ascii / 40) (see assets/genPlaceholderArt.py),