Lots more work on the API and performance improvements.

This commit is contained in:
Scott Duensing 2026-07-01 02:27:13 -05:00
parent add3f3dd0d
commit a0931e8d16
90 changed files with 739 additions and 345 deletions

104
PERF.md
View file

@ -1,39 +1,70 @@
# JoeyLib cross-port performance (UBER benchmark)
Regenerated 2026-06-30. ST/Amiga/DOS columns are fresh measurements from
the headless harness (`scripts/bench-atarist.sh` Hatari, `bench-amiga.sh`
FS-UAE, `bench-dos.sh` DOSBox). The IIgs column is the perf reference: the
generic+override redesign was bit-identical on IIgs (MAME `iigs-verify`
checksum 0817FE), so its codegen — and thus its ops/sec — is unchanged from
the last IIgs capture. Every non-IIgs cell is a ratio (port / IIgs); the
directive is that every target meet or beat the IIgs floor (>= 1.0x).
Sprite ops now meet or beat the IIgs on BOTH 68k targets after this session's
work (compiled DRAW emitters + raw-plane-byte save/restore); they were the
dominant violations (down to 0.13x). The ST column is honest for the first
time — the ST port was crashing on first present (user-mode I/O poke) until
the `Super(0L)` fix in jlpInit.
Genuine remaining sub-1.0x cells and why:
* **ST jlDrawCircle (0.61-0.65x)** — inherent planar-outline cost: a 4-plane
word RMW per sparse outline pixel (8 memory accesses) vs the IIgs single
nibble, amplified by ST video-DMA contention. Cycle-bound; ~0.80x ceiling
even fully optimized. Not winnable in this format.
* **Amiga jlFillCircle (0.82x)** — already asm span-fill (long-store middles);
closing the gap needs a blitter area-fill rewrite (risk: the blitter has
wedged the system before).
* **ST jlSpriteSaveAndDraw (0.97x)** — ST runs save+draw as two dispatched
ops; the IIgs fuses them. Marginal.
* **ST jlTileFill (0.98x), Amiga jlFillRect 80x80 (0.95x)** — within noise.
* **ST jlFillRect 320x200 (0.53x)** — ARTIFACT: the IIgs reference (60) is the
SEI-inflated full-screen reading (see the ceiling note below; honest ~28).
Against the honest reference ST is ~1.1x. Same caveat for surfaceClear /
stagePresent full.
| Op | IIgs (ops/sec) | Amiga (vs IIGS) | Atari ST (vs IIGS) | DOS (vs IIGS) |
| --- | --- | --- | --- | --- |
| jlSurfaceClear | 28 | 2.54x | 1.11x | 638.57x |
| jlPaletteSet | 678 | 8.68x | 4.23x | 26.13x |
| jlScbSetRange | 1005 | 3.66x | 1.86x | 11.16x |
| jlDrawPixel | 1755 | 1.85x | 1.01x | 9.70x |
| jlDrawLine H | 682 | 2.19x | 1.22x | 19.26x |
| jlDrawLine V | 90 | 1.67x | 1.11x | 115.30x |
| jlDrawLine diag | 35 | 1.14x | 1.06x | 261.49x |
| jlDrawRect 100x100 | 75 | 1.57x | 1.04x | 250.77x |
| jlDrawCircle r=16 | 232 | 1.21x | **0.65x** | 71.24x |
| jlDrawCircle r=80 | 56 | 1.16x | **0.61x** | 310.93x |
| jlFillRect 16x16 | 450 | 1.24x | 1.04x | 39.38x |
| jlFillRect 80x80 | 75 | **0.95x** | 1.28x | 206.73x |
| jlFillRect 320x200 | 60 | **0.93x** | **0.43x** | 184.62x |
| jlFillCircle r=40 | 38 | **0.97x** | 1.39x | 347.24x |
| jlSamplePixel | 1916 | 3.48x | 1.92x | 18.04x |
| jlTileFill | 1252 | 1.73x | **0.93x** | 10.44x |
| jlTileCopy | 997 | 1.80x | 1.02x | 16.96x |
| jlTileCopyMasked | 498 | 1.76x | 1.08x | 28.55x |
| jlTilePaste | 1106 | 1.94x | 1.09x | 13.53x |
| jlTileSnap | 1473 | 2.26x | 1.28x | 9.44x |
| jlSpriteSaveUnder | 528 | 2.21x | 1.29x | 20.14x |
| jlSpriteDraw | 438 | 1.82x | 1.28x | 38.39x |
| jlSpriteRestoreUnder | 487 | 2.00x | 1.11x | 36.71x |
| jlSpriteSaveAndDraw | 277 | 1.82x | 1.06x | 72.79x |
| jlStagePresent full | 42 | 6.31x | 1.40x | 373.83x |
| jlInputPoll | 273 | 13.90x | 4.19x | 57.15x |
| jlKeyDown | 3382 | 7.82x | 3.76x | 18.93x |
| jlKeyPressed | 3345 | 7.65x | 3.81x | 18.72x |
| jlMouseX | 4170 | 10.26x | 5.22x | 26.27x |
| joeyJoyConnected | 3378 | 7.76x | 3.71x | 18.95x |
| jlAudioFrameTick | 4106 | 7.99x | 3.04x | 12.37x |
| jlAudioIsPlayingMod | 3536 | 9.28x | 4.33x | 26.78x |
| surfaceMarkDirtyRect (via jlFillRect 32x32) | 240 | 1.38x | 1.26x | 58.95x |
| c2p full screen | - | - | - | - |
| jlSurfaceClear | 28 | 2.68x | 1.32x | 449.21x |
| jlPaletteSet | 678 | 9.09x | 4.27x | 22.01x |
| jlScbSetRange | 1005 | 3.62x | 1.86x | 14.20x |
| jlDrawPixel | 1755 | 2.04x | 1.18x | 9.40x |
| jlDrawLine H | 682 | 2.26x | 1.61x | 26.93x |
| jlDrawLine V | 90 | 1.70x | 1.14x | 145.83x |
| jlDrawLine diag | 35 | 1.14x | 1.06x | 475.37x |
| jlDrawRect 100x100 | 75 | 1.67x | 1.16x | 206.49x |
| jlDrawCircle r=16 | 232 | 1.20x | **0.65x** | 64.79x |
| jlDrawCircle r=80 | 56 | 1.16x | **0.61x** | 301.25x |
| jlFillRect 16x16 | 450 | 1.24x | 1.11x | 36.04x |
| jlFillRect 80x80 | 75 | **0.95x** | 1.33x | 205.09x |
| jlFillRect 320x200 | 60 | 1.13x | **0.53x** | 188.92x |
| jlFillCircle r=40 | 38 | **0.82x** | 1.39x | 398.24x |
| jlSamplePixel | 1916 | 3.42x | 1.92x | 18.04x |
| jlTileFill | 1252 | 1.74x | **0.98x** | 10.60x |
| jlTileCopy | 997 | 1.80x | 1.04x | 7.88x |
| jlTileCopyMasked | 498 | 1.81x | 1.10x | 32.48x |
| jlTilePaste | 1106 | 1.99x | 1.12x | 14.64x |
| jlTileSnap | 1473 | 2.29x | 1.53x | 9.96x |
| jlSpriteSaveUnder | 528 | 1.05x | 1.29x | 23.69x |
| jlSpriteDraw | 438 | 2.09x | 1.09x | 23.85x |
| jlSpriteRestoreUnder | 487 | 1.08x | 1.11x | 34.83x |
| jlSpriteSaveAndDraw | 277 | 1.23x | **0.97x** | 53.79x |
| jlStagePresent full | 42 | 7.81x | 1.10x | 412.17x |
| jlInputPoll | 273 | 13.94x | 4.19x | 37.87x |
| jlKeyDown | 3382 | 7.75x | 3.76x | 18.94x |
| jlKeyPressed | 3345 | 7.66x | 3.81x | 18.73x |
| jlMouseX | 4170 | 10.40x | 5.22x | 26.30x |
| joeyJoyConnected | 3378 | 7.71x | 3.71x | 18.96x |
| jlAudioFrameTick | 4106 | 8.00x | 2.50x | 12.42x |
| jlAudioIsPlayingMod | 3536 | 9.30x | 4.33x | 26.78x |
| surfaceMarkDirtyRect (via jlFillRect 32x32) | 240 | 1.42x | 1.31x | 47.12x |
> **IIgs full-screen ceiling (~29 ops/sec).** A full-screen op moves
> 32000 bytes = 16000 word-stores x 6 cyc (STA long,X / PEI, both
@ -43,6 +74,5 @@
> clock was perturbed. The clock is `jlFrameCount` = the VBL-interrupt-
> driven GetTick, so an op that holds `SEI` while it runs can over-
> report its ops/sec. Treat full-screen IIgs numbers above ~29 as
> inflated and cross-check against the cycle model. (`jlFillRect 320x200`
> = 60 and `jlStagePresent full` = 42 in the table above predate the
> Phase 0 SEI removal and are inflated; expect ~25-28 after re-measure.)
> inflated and cross-check against the cycle model.

View file

@ -40,7 +40,8 @@ plus the inline scripts run during verification sessions.
| `$71CE` (fares done) INCs ONLY at `$6BD6` | grep finds only one INC (`$6BD6`) and one STA (`$5EE9 = 0` at game init). |
| `$7213` (fare target) is player-selectable 1..4 at title | Writers: `$48AF` sets to 1 (default); `$5310-$5328` is a title-screen joystick UI -- RIGHT INCs, LEFT DECs, ANDed with `#$03` to clamp 0..3 (= 1..4 with +1 baseline). |
| Game-end goes to `$5EC4` (gameInitContinue, restarts to title) | `$6BE1: JMP $602F`, `$602F: JMP $5EC4`. So Space Taxi's "level complete" just resets the game -- no inter-level progression in the original. |
| `romToLevel.py` bug: levels A/O/R had fareCount=0 (uncompletable) | Verified by parsing all 24 .dat files. Fixed: single-pad levels now emit `(0, 0)` self-loop fare. |
| `romToLevel.py` `fareCount` and the `(0,0)` self-loop were BOTH fabricated | Deep disassembly (padHoverSetup `$6537`, padDetect `$64CC`, `$6CB8`) proves there is NO static fare list: spawn pad AND destination are RNG-chosen at runtime (`$4080`) in `[1..padCount]`; when all pads are used the destination is the `$0B` "UP PLEASE!" transporter sentinel. Fare count = `$7213` (the GAME-VARIATIONS "cabbies" setting), global not per-level. FIX: `romToLevel` emits `fareCount=0`; the port (`stPassenger.c`) ports the runtime RNG selection. Empirically verified in DOSBox: land->board->"up please!"->fly up through the top-wall transporter->fare delivered (+5)->advance to level B. |
| `romToLevel.py` pad tileX/tileY had NO VIC origin subtraction (pads off-field) | Pad slot bytes are VIC sprite-register coords. FIX (verified exact on 4 pads across scenes A/B, matched to live screen RAM): `tileX=(x1-24)//8`, `tileY=(padRow-36)//8` (36 = sprite-Y origin 50 - 14px landing-gear). Level A pad: was tileX13/tileY25(off-field), now tileX10/tileY21 (on the rendered pad). Also the taxi spawn was hardcoded `(18,2)`; real spawn is `$7D5B-5F` (level A = tile 30,3). |
| Full `$5F40` main-loop body verified: 25 JSRs in exact order matching MECHANICS.md | With stubs for KERNAL `$FFE1`, `$$404B` (vblWait), `$44EF` (waitTickSpin), `$44D2` (waitVoicesIdle), and `$7D65 = $80` so `$6BE7` doesn't early-exit: 716 instructions, 25 depth-1 JSRs captured in documented order. |
| `$4BF8` (CIA1 DDR setup) is called every main-loop iteration, not "once" | Verified by trace: appears in the depth-1 chain at PC `$5FB5`. Setting CIA DDRs each frame is wasteful but harmless. |
| Emulator now covers all opcodes used by the main-loop body | Added INC/DEC absx/zpx, ASL/LSR/ROL/ROR zpx/absx, LDA/STA/AND/ORA/EOR/ADC/SBC/CMP indx, CPX/CPY zp. The main loop runs to completion through these instructions. |

View file

@ -32,9 +32,9 @@ PALETTE_RGB = [
]
def new_canvas():
"""Returns a fresh 320x200 indexed PIL image with the palette installed."""
img = Image.new('P', (320, 200), 0)
def new_canvas(w=320, h=200):
"""Returns a fresh w x h indexed PIL image with the palette installed."""
img = Image.new('P', (w, h), 0)
flat = []
for r, g, b in PALETTE_RGB:
flat.extend([r, g, b])
@ -120,11 +120,13 @@ def gen_tilebank():
def gen_sprites():
"""sprites.png: 320x200, layout:
y= 0..23 : taxi cels (4 frames of 24x24 starting at x=0)
y= 24..39 : passenger walk cels (4 frames of 16x16 at x=0)
Remaining rows free for future content.
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.
"""
img = new_canvas()
img = new_canvas(9 * 24, 3 * 24) # 216x72: 9 cols x 3 rows of 24x24
d = ImageDraw.Draw(img)
# ---- Taxi cels: simple side-view "pod with thruster" ----
@ -149,6 +151,24 @@ def gen_sprites():
(x+15, flame_top),
(x+12, flame_bot)], fill=flame_color)
# ---- Taxi shrink cels (transporter warp): the cab receding into the
# distance, 5 progressively smaller sizes, centered in the 24x24 cell.
# Placed in the free row-0 slots (cels 4..8). No flame -- it is warping
# away, not thrusting. The renderer draws one per warp step ($E2..$E9
# on the C64 = 8 shrink cels; the port approximates with 5 sizes).
for i in range(5):
cx = (4 + i) * 24 + 12
scale = 1.0 - i * 0.19 # 1.0, 0.81, 0.62, 0.43, 0.24
bw = max(3, int(round(19 * scale)))
bh = max(2, int(round(12 * scale)))
x0 = cx - bw // 2
y0 = 12 - bh // 2
# Pod body (gray, white outline)
d.rectangle([x0, y0, x0 + bw, y0 + bh], fill=7, outline=15)
# Cockpit window (blue) while big enough to show
if bw >= 8:
d.rectangle([x0 + bw - 5, y0 + 1, x0 + bw - 1, y0 + 3], fill=8)
# ---- Passenger cels: small stick figure, walk cycle ----
# Centered in 16x16, walking right.
for cel in range(4):
@ -179,6 +199,12 @@ def gen_font():
Authored manually for the printable subset (0x20..0x7E).
Each glyph cell is at (ascii%40, ascii/40) when ascii<128.
Unfilled cells stay black (treated as TILE_NO_GLYPH at runtime).
WARNING: this generator is UPPERCASE-ONLY (lowercase maps to the
uppercase glyphs). The COMMITTED font.png has been hand-extended with
real lowercase glyphs, so running this clobbers it -- the HUD reverts
to all-caps. Regenerate SPRITES only (gen_sprites) unless you also add
the lowercase glyphs here first; then `git checkout` font.png.
"""
# A compact 5-pixel-wide bitmap font, drawn in 7 rows with 1px
# padding on all sides (so 5x7 active inside an 8x8 cell, baseline

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

View file

@ -1,5 +1,5 @@
// mkstlevel: convert a human-readable text level definition into the
// STL2 binary format read by examples/spacetaxi/stLevel.c.
// STL3 binary format read by examples/spacetaxi/stLevel.c.
//
// Usage:
// mkstlevel input.txt output.dat
@ -15,8 +15,10 @@
// @accel <xAccel> <yAccel> [optional, default 14 14]
// @gravity <xGrav> <yGrav> [optional, default 0 1; yGrav signed]
// @vicColor <bg1> <bg2> <bg3> <mc0> <mc1> <sc0> <sc1> [optional, default all 0]
// @pad <letter> <tileX> <tileY> <tileW> [up to 10 of these]
// @fare <spawnLetter> <destLetter> [up to 16 of these]
// @pad <letter> <tileX> <tileY> <tileW> [standX] [up to 10 of these;
// letter is a readability label only (not stored);
// standX = passenger stand column in pixels, optional
// (defaults to the pad's left edge, tileX*8)]
// @tilemap -- followed by exactly 25 lines of 40 chars each
// @colormap -- followed by exactly 25 lines of 40 chars each
//
@ -45,25 +47,18 @@
#define ST_TILEMAP_W 40
#define ST_PLAYFIELD_ROWS 25
#define ST_MAX_PADS 10
#define ST_MAX_FARES 16
#define ST_NAME_MAX 23
#define LINE_MAX 256
typedef struct {
uint8_t letter;
uint8_t tileX;
uint8_t tileY;
uint8_t tileW;
uint8_t standX; // passenger stand column, screen pixels
} PadT;
typedef struct {
uint8_t spawnPad;
uint8_t destPad;
} FareT;
typedef struct {
char name[ST_NAME_MAX + 1];
uint8_t tileBank;
@ -85,15 +80,12 @@ typedef struct {
uint8_t sprite1Color;
uint8_t padCount;
PadT pads[ST_MAX_PADS];
uint8_t fareCount;
FareT fares[ST_MAX_FARES];
uint8_t tilemap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS];
uint8_t colormap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS];
} LevelT;
static int charToIndex(int c, int *outIdx);
static int findPadByLetter(const LevelT *L, uint8_t letter);
static int parseGrid(FILE *fp, const char *gridName, uint8_t *outCells, int *outLine);
static int parseLevel(FILE *fp, LevelT *out);
static int readLine(FILE *fp, char *buf, int n);
@ -113,17 +105,6 @@ static int charToIndex(int c, int *outIdx) {
}
static int findPadByLetter(const LevelT *L, uint8_t letter) {
uint8_t i;
for (i = 0; i < L->padCount; i++) {
if (L->pads[i].letter == letter) {
return (int)i;
}
}
return -1;
}
// Reads one line; returns >0 on success, 0 on EOF, -1 on error.
// Strips trailing newline. Lines starting with '#' are returned with
// the leading '#' so the caller can decide whether to skip them.
@ -311,11 +292,14 @@ static int parseLevel(FILE *fp, LevelT *out) {
out->sprite0Color = (uint8_t)sc0;
out->sprite1Color = (uint8_t)sc1;
} else if (strncmp(line, "@pad ", 5) == 0) {
char letter = '\0';
char letter = '\0'; // kept in the syntax for readability only
int px = 0;
int py = 0;
int pw = 0;
if (sscanf(line + 5, " %c %d %d %d", &letter, &px, &py, &pw) != 4) {
int sx = -1; // optional passenger stand X (pixels)
int got = sscanf(line + 5, " %c %d %d %d %d",
&letter, &px, &py, &pw, &sx);
if (got != 4 && got != 5) {
fprintf(stderr, "mkstlevel: line %d: bad pad\n", lineNo);
return 0;
}
@ -324,36 +308,13 @@ static int parseLevel(FILE *fp, LevelT *out) {
lineNo, ST_MAX_PADS);
return 0;
}
out->pads[out->padCount].letter = (uint8_t)letter;
out->pads[out->padCount].tileX = (uint8_t)px;
out->pads[out->padCount].tileY = (uint8_t)py;
out->pads[out->padCount].tileW = (uint8_t)pw;
// Default the passenger stand column to the pad's left edge.
out->pads[out->padCount].standX =
(uint8_t)((sx >= 0) ? sx : (px * 8));
out->padCount++;
} else if (strncmp(line, "@fare ", 6) == 0) {
char src = '\0';
char dst = '\0';
int srcIdx;
int dstIdx;
if (sscanf(line + 6, " %c %c", &src, &dst) != 2) {
fprintf(stderr, "mkstlevel: line %d: bad fare\n", lineNo);
return 0;
}
srcIdx = findPadByLetter(out, (uint8_t)src);
dstIdx = findPadByLetter(out, (uint8_t)dst);
if (srcIdx < 0 || dstIdx < 0) {
fprintf(stderr,
"mkstlevel: line %d: fare references unknown pad (src '%c' dst '%c')\n",
lineNo, src, dst);
return 0;
}
if (out->fareCount >= ST_MAX_FARES) {
fprintf(stderr, "mkstlevel: line %d: too many fares (max %d)\n",
lineNo, ST_MAX_FARES);
return 0;
}
out->fares[out->fareCount].spawnPad = (uint8_t)srcIdx;
out->fares[out->fareCount].destPad = (uint8_t)dstIdx;
out->fareCount++;
} else if (strcmp(line, "@tilemap") == 0) {
if (!parseGrid(fp, "tilemap", out->tilemap, &lineNo)) {
return 0;
@ -403,7 +364,7 @@ static int writeOut(const LevelT *L, const char *path) {
return 0;
}
if (fputc('S', fp) == EOF || fputc('T', fp) == EOF ||
fputc('L', fp) == EOF || fputc('2', fp) == EOF) {
fputc('L', fp) == EOF || fputc('3', fp) == EOF) {
goto fail;
}
nameLen = (uint8_t)strlen(L->name);
@ -440,22 +401,14 @@ static int writeOut(const LevelT *L, const char *path) {
goto fail;
}
for (i = 0; i < L->padCount; i++) {
if (fputc(L->pads[i].letter, fp) == EOF ||
fputc(L->pads[i].tileX, fp) == EOF ||
if (fputc(L->pads[i].tileX, fp) == EOF ||
fputc(L->pads[i].tileY, fp) == EOF ||
fputc(L->pads[i].tileW, fp) == EOF) {
goto fail;
}
}
if (fputc(L->fareCount, fp) == EOF) {
goto fail;
}
for (i = 0; i < L->fareCount; i++) {
if (fputc(L->fares[i].spawnPad, fp) == EOF ||
fputc(L->fares[i].destPad, fp) == EOF) {
fputc(L->pads[i].tileW, fp) == EOF ||
fputc(L->pads[i].standX, fp) == EOF) {
goto fail;
}
}
// STL3 stores no fares -- fares are RNG-chosen at runtime.
cells = (size_t)ST_TILEMAP_W * ST_PLAYFIELD_ROWS;
if (fwrite(L->tilemap, 1, cells, fp) != cells) {
goto fail;
@ -493,7 +446,7 @@ int main(int argc, char **argv) {
if (!writeOut(&L, argv[2])) {
return 1;
}
printf("mkstlevel: wrote %s (name=\"%s\", pads=%u, fares=%u)\n",
argv[2], L.name, (unsigned)L.padCount, (unsigned)L.fareCount);
printf("mkstlevel: wrote %s (name=\"%s\", pads=%u)\n",
argv[2], L.name, (unsigned)L.padCount);
return 0;
}

View file

@ -163,18 +163,75 @@ int main(void) {
switch (game.state) {
case ST_STATE_TITLE:
{
// C64 title only listens for UP / DOWN / FIRE per the
// baked joystick instructions ("UP FOR HIGH SCORES,
// DOWN FOR INSTRUCTIONS, FIRE BUTTON TO BEGIN"). The
// fare-count selector ($5310-$5328) lives on a separate
// options/menu scene at $5295 (header strings) and
// $52E1/$533C (the "1 2 3 4" digit row), not the main
// title. UP / DOWN / options screen are TODO; for now
// the title only honors FIRE -> begin.
// C64 title listens for UP (high scores), DOWN
// (instructions), and FIRE. FIRE on the title does NOT
// start gameplay -- it advances to the "GAME VARIATIONS"
// options menu ($5295, reached via $4750 -> JSR $5295),
// where the player selects the cabbie count before the
// game begins. (UP/DOWN high-scores/instructions screens
// are still TODO.)
{
int8_t jy = jlJoystickY(JOYSTICK_0);
if (jlKeyDown(KEY_UP) || jy < -32) {
game.state = ST_STATE_HIGHSCORES;
break;
}
if (jlKeyDown(KEY_DOWN) || jy > 32) {
game.state = ST_STATE_INSTRUCTIONS;
break;
}
}
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
game.fareTarget = 1u; // C64 default ($48AF sets 1)
game.state = ST_STATE_OPTIONS;
}
break;
}
case ST_STATE_HIGHSCORES:
case ST_STATE_INSTRUCTIONS:
// Both are title sub-screens; FIRE returns to the title.
// The screens cleared the stage, so force a full title
// tilemap repaint on the way back.
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
stRenderLevelChanged();
game.state = ST_STATE_TITLE;
}
break;
case ST_STATE_OPTIONS:
{
// "GAME VARIATIONS" menu ($5295). LEFT/RIGHT cycle the
// cabbie count 1..4 (C64 stores 0..3 in $7213 and wraps
// with AND #$03 -- so it rolls 4->1 and 1->4); FIRE
// confirms and starts the game. LEFT/RIGHT are edge-
// triggered so one press = one step.
static bool prevLeft = false;
static bool prevRight = false;
int8_t jx = jlJoystickX(JOYSTICK_0);
bool left = jlKeyDown(KEY_LEFT) || jx < -32;
bool right = jlKeyDown(KEY_RIGHT) || jx > 32;
uint8_t sel = (uint8_t)((game.fareTarget - 1u) & 3u);
if (right && !prevRight) {
sel = (uint8_t)((sel + 1u) & 3u);
}
if (left && !prevLeft) {
sel = (uint8_t)((sel - 1u) & 3u);
}
game.fareTarget = (uint8_t)(sel + 1u);
prevLeft = left;
prevRight = right;
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
game.score = 0;
game.lives = 5;
// "SELECT NUMBER OF CABBIES" ($7213) sets the number of
// taxi LIVES, not a fare quota: gFareCounter increments
// only on the crash path ($6BD6), and the game ends when
// crashes reach the cabbie count. Deliveries never end
// the game; you advance screens by flying up.
game.lives = game.fareTarget;
game.levelIndex = 0;
if (!loadLevelByIndex(&game, 0)) {
jlLogF("spacetaxi: ! cannot load level 0 (DATA/levels/level01.dat)");
@ -182,15 +239,24 @@ int main(void) {
goto shutdown;
}
stEngineReset(&game);
// C64 goes straight from title to gameplay; no
// intermediate "PRESS FIRE TO START" screen.
game.state = ST_STATE_PLAYING;
}
break;
}
case ST_STATE_PLAYING:
{
bool wasLanded = game.taxi.landed;
bool wasLanded;
// Transporter shrink-warp in progress: suspend input and
// physics, just advance the recede animation; when it
// finishes, deliver any "UP PLEASE" fare and change screens.
if (game.taxi.warpFrame > 0u) {
if (stEngineWarpTick(&game)) {
stPassengerTransporterExit(&game);
game.state = ST_STATE_LEVEL_DONE;
}
break;
}
wasLanded = game.taxi.landed;
applyInput(&game);
stEngineTick(&game);
stPassengerTick(&game);
@ -199,6 +265,13 @@ int main(void) {
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:

View file

@ -39,6 +39,13 @@
#define ST_TILEMAP_H 25u
#define ST_TILE_PIXELS 8u
// The tilemap stores raw C64 screen-RAM character codes. The empty
// (non-colliding) playfield cell is the space character, $20. Every
// other code is non-blank background -- on the C64 it latches a sprite-
// background collision ($D01F). romToLevel.py uses BG=0x20 as the empty
// marker when extracting screen RAM.
#define ST_TILE_EMPTY 0x20u
// Display field is 320x200 (JoeyLib's SURFACE_WIDTH x SURFACE_HEIGHT).
// 40 tiles x 8 px = 320, 25 tiles x 8 px = 200. The bottom 3 rows are
// the HUD band (score / lives / level / current-fare strip). The top
@ -53,10 +60,28 @@
// Maximum stuff. Tuned for fitting the smallest target (IIgs):
// 10 pads is the highest count in any canonical Space Taxi level
// (D and M each have 10 pads; everything else <= 9)
// 2 active passenger sprites covers waiting + carrying
// Exactly ONE fare exists at a time. Verified: the C64 spawns a new
// fare only while its state machine (gDeathStage) is idle, so
// waiting-vs-carrying is the single passenger's `onboard` flag -- NOT
// two passengers. (A prior "waiting + carrying = 2 sprites" note was
// a misconception / false second source of truth.)
#define ST_MAX_PADS 10u
#define ST_MAX_PASSENGERS 2u
#define ST_MAX_FARES 16u
#define ST_MAX_PASSENGERS 1u
// Passenger destination sentinel: "UP PLEASE!" -- the fare wants to be
// flown UP through the top-wall transporter opening rather than to a
// landing pad. This is the C64's $0B destination ($6CB8), used on
// single-pad screens (and when all pads are consumed). Any real pad
// index is 0..ST_MAX_PADS-1, so 0xFE can't collide.
#define ST_DEST_TRANSPORTER 0xFEu
// The transporter opening is a fixed 4-column gap ($67 tiles) in the top
// wall, at columns 18..21 in every canonical Space Taxi screen (verified
// across the extracted tilemaps). Flying the taxi up through it exits the
// screen.
#define ST_TRANSPORTER_COL_LO 18u
#define ST_TRANSPORTER_COL_HI 22u // exclusive
#define ST_TILE_TRANSPORTER 0x67u
// Fixed-point taxi physics: position and velocity are int16_t in
// units of 1/16 px, so the taxi can drift fractionally and the
@ -84,6 +109,9 @@
typedef enum {
ST_STATE_TITLE = 0,
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_PLAYING,
ST_STATE_LEVEL_DONE,
ST_STATE_GAME_OVER
@ -97,19 +125,13 @@ typedef enum {
typedef struct {
uint8_t letter; // 'A'..'H' identifier (which pad number)
uint8_t tileX; // landing surface left edge (tile coord)
uint8_t tileY; // landing surface row (tile coord)
uint8_t tileW; // pad width in tiles
uint8_t standX; // passenger stand X (screen px; slot byte6-24)
} StPadT;
typedef struct {
uint8_t spawnPad; // pad index where they appear
uint8_t destPad; // pad index they want to go to
} StFareT;
typedef struct {
char name[24]; // level display name ("UP & DOWN", etc.)
uint8_t tileBankId; // which tile asset (0 = default bank)
@ -150,8 +172,6 @@ typedef struct {
uint8_t sprite1Color; // $D028 sprite 1 (flame)
uint8_t padCount;
StPadT pads[ST_MAX_PADS];
uint8_t fareCount;
StFareT fares[ST_MAX_FARES];
// Tilemap: tile-index per cell (row-major).
uint8_t tilemap[ST_TILEMAP_W * ST_PLAYFIELD_ROWS];
// Palette slot per cell -- which surface palette index a cell uses.
@ -180,6 +200,10 @@ typedef struct {
// branch"). Reaches 0 -> respawn (or game-over). Mirrors the
// C64's $6B24/$6B4C phase-2/3 timing.
uint8_t crashTicks;
// 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.
uint8_t warpFrame;
} StTaxiT;
@ -203,10 +227,10 @@ typedef struct {
uint32_t score;
uint8_t lives;
uint8_t levelIndex;
// Player-selectable fare target (1..4). Mirrors C64 $7213, set on
// the title screen via LEFT/RIGHT joystick (see $5310-$5328 in
// MECHANICS.md). Used to cap the per-level fare count: the engine
// delivers min(level.fareCount, game.fareTarget) before advancing.
// 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
// fares to deliver before the game completes -- NOT per-level.
uint8_t fareTarget;
} StGameT;
@ -232,9 +256,21 @@ void stRenderDrawText(jlSurfaceT *stage, uint8_t bx, uint8_t by, const char *s);
void stEngineReset(StGameT *game);
void stEngineTick(StGameT *game);
// True when the taxi has flown up into the top-wall transporter opening
// (used to trigger a screen exit).
bool stEngineInTransporter(const StGameT *game);
// Begin the transporter shrink-warp (taxi.warpFrame 0 -> 1).
void stEngineStartWarp(StGameT *game);
// Advance the shrink-warp one frame; returns true when it completes.
bool stEngineWarpTick(StGameT *game);
// Current shrink step 0..7 (0 = full size, 7 = smallest) for rendering.
uint8_t stEngineWarpStep(const StGameT *game);
void stPassengerReset(StGameT *game);
void stPassengerTick(StGameT *game);
// Called when the taxi exits up through the transporter: delivers a
// carried "UP PLEASE" fare (if any) before the screen advances.
void stPassengerTransporterExit(StGameT *game);
void stAudioInit(void);
void stAudioShutdown(void);

View file

@ -86,12 +86,22 @@ static bool isSolidAt(const StLevelT *level, int16_t px, int16_t py) {
return true; // top/bottom out-of-field treated as walls
}
tile = level->tilemap[ty * ST_TILEMAP_W + tx];
// Tile-index convention (authored by the level designer):
// 0 = empty space
// 1..63 = solid (walls, platforms, structure)
// 64..127 = landing-pad surfaces (also solid for collision)
// 128+ = decorative non-solid (lights, signs, etc.)
return tile != 0u && tile < 128u;
// The tilemap holds raw C64 screen-RAM character codes (romToLevel.py
// copies screen RAM directly; the empty cell is the space character
// $20, BG=0x20 in the extractor -- confirmed by the .dat histogram
// where char 32 dominates the tilemap). On the C64 a sprite-vs-
// background collision ($D01F) latches on ANY non-blank background
// cell the cab overlaps, so a cell is solid iff its char code is not
// the space character. (The earlier "1..63 solid / 64..127 pad /
// 128+ decorative" convention was invented and is wrong against the
// real data -- it treated char 32, the empty playfield, as solid,
// which crashes the cab the instant it descends into open air.)
//
// The transporter opening ($67, the 4-tile top-wall gap) is the one
// non-space tile the cab may pass through: it's the screen exit. It
// only ever occurs at row 0 cols 18..21, so treating it as passable
// everywhere is safe.
return tile != ST_TILE_EMPTY && tile != ST_TILE_TRANSPORTER;
}
@ -297,7 +307,15 @@ void stEngineTick(StGameT *game) {
if (t->vy > 0) {
if (isSolidAt(L, centerX, feetY)) {
if (onLandingPad(L, centerX, feetY, &landingPad)) {
// The C64 pad detector ($645C) only registers a landing on a
// SLOW descent: it gates on the Y-velocity high byte being
// zero (vy < 1 px/frame). A fast descent fails the gate, so
// the pad surface acts as solid background and the cab
// 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).
bool slowDescent = (t->vy < ST_SUBPIXEL);
if (slowDescent && 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;
@ -337,3 +355,81 @@ void stEngineTick(StGameT *game) {
t->y = ny;
reflectAtEdges(t);
}
bool stEngineInTransporter(const StGameT *game) {
const StTaxiT *t = &game->taxi;
int16_t topY;
int16_t centerCol;
// The cab has exited when its top has risen into row 0 while its
// center sits within the transporter columns (18..21). The opening
// tile ($67) is passable (see isSolidAt), so the cab can ascend the
// empty channel and reach row 0 only through the gap -- the walls
// stop it everywhere else.
topY = (int16_t)(t->y >> ST_SUBPIXEL_SHIFT);
centerCol = (int16_t)(((t->x >> ST_SUBPIXEL_SHIFT) + ST_TAXI_W_PX / 2)
/ ST_TILE_PIXELS);
// Fire once the cab has risen into the opening (top within rows 0-1).
// The walls block every column but the 18-21 gap, so reaching here at
// all means the cab is exiting through the transporter.
return topY < (int16_t)(2 * ST_TILE_PIXELS) &&
centerCol >= (int16_t)ST_TRANSPORTER_COL_LO &&
centerCol < (int16_t)ST_TRANSPORTER_COL_HI;
}
// 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.
#define ST_WARP_TOTAL_FRAMES 64u
static const uint8_t kWarpStepFrame[8] = { 0u, 4u, 8u, 12u, 16u, 32u, 48u, 64u };
uint8_t stEngineWarpStep(const StGameT *game) {
uint8_t frame = game->taxi.warpFrame;
uint8_t step = 0u;
uint8_t k;
for (k = 0u; k < 8u; k++) {
if (frame >= kWarpStepFrame[k]) {
step = k;
}
}
return step;
}
void stEngineStartWarp(StGameT *game) {
game->taxi.warpFrame = 1u;
game->taxi.vx = 0;
game->taxi.vy = 0;
game->taxi.thrusting = false;
stAudioSfxCrash(); // the C64 sets voice-3 noise ($81) on warp start
}
bool stEngineWarpTick(StGameT *game) {
StTaxiT *t = &game->taxi;
uint8_t before;
uint8_t after;
before = stEngineWarpStep(game);
t->warpFrame++;
after = stEngineWarpStep(game);
// Nudge +1px right / -1px up on each step boundary (net +7/-7).
if (after != before) {
t->x += (int32_t)ST_SUBPIXEL;
t->y -= (int32_t)ST_SUBPIXEL;
}
if (t->warpFrame >= (uint8_t)ST_WARP_TOTAL_FRAMES) {
t->warpFrame = 0u;
return true;
}
return false;
}

View file

@ -54,25 +54,25 @@ void stHudDraw(jlSurfaceT *stage, const StGameT *game) {
stRenderDrawText(stage, col, (uint8_t)ST_HUD_ROW, game->level.name);
}
// Active fare display: where the current passenger wants to go
// (or "PAD x WANTS y" when one is still waiting to be picked up).
// Active-fare message, matching the C64 HUD ($6C1E "PAD PLEASE",
// $6C2B " UP PLEASE!"): a waiting fare hails ("HEY TAXI!"); once
// aboard it announces its destination by pad NUMBER (index+1, per
// $671E which draws the destination index + '0'), or "UP PLEASE!"
// when the exit is the transporter.
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
const StPassengerT *p = &game->passengers[i];
if (p->active && p->onboard &&
p->destPad < game->level.padCount) {
char dest = (char)game->level.pads[p->destPad].letter;
snprintf(buf, sizeof(buf), "FARE %c", dest);
if (!p->active) {
continue;
}
if (!p->onboard) {
snprintf(buf, sizeof(buf), "HEY TAXI!");
} else if (p->destPad == ST_DEST_TRANSPORTER) {
snprintf(buf, sizeof(buf), "UP PLEASE!");
} else {
snprintf(buf, sizeof(buf), "PAD %u PLEASE!",
(unsigned)(p->destPad + 1u));
}
stRenderDrawText(stage, 0u, (uint8_t)(ST_HUD_ROW + 1u), buf);
break;
}
if (p->active && !p->onboard &&
p->currentPad < game->level.padCount) {
char src = (char)game->level.pads[p->currentPad].letter;
char dest = (p->destPad < game->level.padCount)
? (char)game->level.pads[p->destPad].letter : '?';
snprintf(buf, sizeof(buf), "PAD %c WANTS %c", src, dest);
stRenderDrawText(stage, 0u, (uint8_t)(ST_HUD_ROW + 1u), buf);
break;
}
}
}

View file

@ -14,13 +14,13 @@
#include "spacetaxi.h"
// STL2 adds the per-level physics templates (xAccel/yAccel/xGrav/yGrav)
// and the full VIC color block ($7D00-$7D08). Drops the patience byte
// from each fare entry (Space Taxi proper has no patience timeout).
// STL3: per-pad record is (tileX, tileY, tileW, standX) where standX is the
// passenger stand column in screen pixels; the STL2 per-pad letter and the
// whole fare list are gone (fares are RNG-chosen at runtime, not stored).
#define ST_LEVEL_MAGIC0 'S'
#define ST_LEVEL_MAGIC1 'T'
#define ST_LEVEL_MAGIC2 'L'
#define ST_LEVEL_MAGIC3 '2'
#define ST_LEVEL_MAGIC3 '3'
static bool readByte(FILE *fp, uint8_t *out) {
@ -108,22 +108,10 @@ bool stLevelLoad(StLevelT *out, const char *path) {
return false;
}
for (i = 0u; i < out->padCount; i++) {
if (!readByte(fp, &out->pads[i].letter) ||
!readByte(fp, &out->pads[i].tileX) ||
if (!readByte(fp, &out->pads[i].tileX) ||
!readByte(fp, &out->pads[i].tileY) ||
!readByte(fp, &out->pads[i].tileW)) {
fclose(fp);
return false;
}
}
if (!readByte(fp, &out->fareCount) || out->fareCount > ST_MAX_FARES) {
fclose(fp);
return false;
}
for (i = 0u; i < out->fareCount; i++) {
if (!readByte(fp, &out->fares[i].spawnPad) ||
!readByte(fp, &out->fares[i].destPad)) {
!readByte(fp, &out->pads[i].tileW) ||
!readByte(fp, &out->pads[i].standX)) {
fclose(fp);
return false;
}

View file

@ -1,20 +1,26 @@
// Space Taxi -- passenger AI.
// Space Taxi -- passenger AI (dynamic fare model).
//
// A passenger has three states:
// 1. Waiting on a pad (active, !onboard) - cycles walk animation.
// Boards when the taxi lands on their pad.
// 2. In the cab (active, onboard) - invisible; followed by
// the taxi until it touches the destination pad.
// 3. Done (!active) - waiting to be replaced
// by the next fare slot.
// The C64 game has NO static per-level fare list (verified: the prior
// port's "circular A->B->..->A" fares were fabricated). Both the SPAWN
// pad and the DELIVERY destination are chosen at RUNTIME by RNG:
//
// When a passenger lands at their destination, the next fare in the
// level's fareCount is spawned. When the level's fares are exhausted,
// the level is done.
// * padHoverSetup ($6537) picks a random free pad in [1..padCount] for
// the passenger to appear on, and again for where they want to go.
// * When all pads are consumed (single-pad screens, notably level A),
// the destination becomes the "UP PLEASE!" sentinel ($0B, $6CB8):
// the fare is delivered by flying UP through the top-wall
// transporter opening.
// * "PAD n PLEASE" is drawn from the runtime destination index+'0'
// ($671E: gActiveSpriteIdx + $30); pads are numeric, not lettered.
//
// This port keeps ONE active fare at a time (spawn -> board -> deliver ->
// spawn next), a faithful reduction of the C64's multi-slot queue. The
// RNG here is a small port-side LCG (the exact C64 RNG sequence isn't
// observable and depends on live seed state); it only needs to pick a
// plausible free pad each fare.
//
// Scoring is flat-rate per the C64 original ($43B9 BCD blob added at
// success-stage 6 / $6742): 500 points per delivered fare. The earlier
// "tip by patience" formula was a port-side invention.
// success-stage 6 / $6742): a delivered fare adds ST_FARE_SCORE.
#include <stddef.h>
@ -27,131 +33,125 @@
#define ST_WALK_FRAMES 4u
#define ST_WALK_TICKS_PER_CEL 6u
// Verified via emulator trace of $4354 (BCD-add) with the $43B9 blob
// against an all-blank HUD: the only HUD position modified is index 3
// (the ones-digit of the integer part in the C64's DDDD.DD layout),
// where digit '5' gets added. So a basic fare delivered = +5 score.
// See stuff/spacetaxi/trace.py and the run output for proof.
//
// Two other blobs exist but their callers haven't been traced:
// $43C1 = +95 (called from $5D03 after a per-passenger counter
// decrement -- looks like a bonus event)
// $43C9 = +50 (called from $5C82 in the $5BBB-dispatched anim --
// possibly a "fare-on-takeoff" or "tip" bonus)
// 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
// score. See stuff/spacetaxi/trace.py.
#define ST_FARE_SCORE 5u
// Spawn cadence (C64 idle ticker $660B): while no fare is active, each
// frame rolls rand%100 and spawns on roll < 3, else counts a 100-frame
// cap down to a forced spawn -- a fresh fare appears within ~1-2 seconds.
#define ST_SPAWN_ROLL_THRESH 3u
#define ST_SPAWN_CAP_FRAMES 100
// Per-port passenger queue cursor. Reset by stPassengerReset whenever a
// new level (or restart-after-game-over) needs to begin from fare 0.
static uint8_t gNextFareIdx;
static uint32_t gRng;
static uint8_t gWalkTick;
// 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
// "used up" as fares are delivered (C64 gSpriteSlots/gFareSlotCount); once
// only the last pad would remain free, the next fare wants "UP PLEASE!"
// (fly up to the next screen), which is what makes each screen finite.
static uint8_t gDeliveredThisScreen;
static uint8_t effectiveFareTarget(const StGameT *game);
static void spawnNextFare(StGameT *game);
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);
// Apply the title-screen fare-count selector ($7213 in the C64) as an
// upper cap on this level's fare count. The level data sets the
// maximum (e.g. 8 for a long delivery chain); player picks 1..4 on
// the title to control session length. The lesser of the two wins.
static uint8_t effectiveFareTarget(const StGameT *game) {
uint8_t levelMax = game->level.fareCount;
uint8_t sel = game->fareTarget;
if (sel == 0u) {
return levelMax;
}
return (levelMax < sel) ? levelMax : sel;
static void deliverFare(StGameT *game, StPassengerT *p) {
game->score += ST_FARE_SCORE;
stAudioSfxDropoff();
p->active = false;
gDeliveredThisScreen++;
// The next fare appears after the spawn cadence (see stPassengerTick),
// not instantly.
gSpawnCountdown = ST_SPAWN_CAP_FRAMES;
}
static void spawnNextFare(StGameT *game) {
const StFareT *fare;
const StPadT *pad;
StPassengerT *slot;
uint8_t i;
// On boarding, choose where the fare wants to go. Destination pads get
// used up as the screen's fares are delivered: once delivering would leave
// no fresh pad (single-pad screens, or after padCount-1 deliveries), the
// fare wants "UP PLEASE!" -- the player flies up to the next screen.
static void pickDestination(StGameT *game, StPassengerT *p) {
uint8_t pc = game->level.padCount;
uint8_t d;
if (gNextFareIdx >= effectiveFareTarget(game)) {
if (pc <= 1u || gDeliveredThisScreen >= (uint8_t)(pc - 1u)) {
p->destPad = ST_DEST_TRANSPORTER;
return;
}
// Find an idle slot.
slot = NULL;
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
if (!game->passengers[i].active) {
slot = &game->passengers[i];
break;
d = rngPad(pc);
if (d == p->currentPad) {
d = (uint8_t)((d + 1u) % pc);
}
}
if (slot == NULL) {
return;
}
fare = &game->level.fares[gNextFareIdx++];
if (fare->spawnPad >= game->level.padCount) {
return;
}
pad = &game->level.pads[fare->spawnPad];
slot->active = true;
slot->onboard = false;
slot->currentPad = fare->spawnPad;
slot->destPad = fare->destPad;
slot->walkPhase = 0u;
slot->walkDir = 0u;
slot->x = (int16_t)(pad->tileX * ST_TILE_PIXELS);
// Pad's tileY is the landing-surface row (top of the pad block).
// Place the passenger so their feet sit on that surface --
// top-left Y is (tileY - passengerHeightInTiles) * tilePixels.
slot->y = (int16_t)((pad->tileY - ST_PASSENGER_H_TILES) * ST_TILE_PIXELS);
p->destPad = d;
}
void stPassengerReset(StGameT *game) {
uint8_t i;
// Stand the passenger at the pad's real stand column (pad->standX, the
// C64 slot byte6 = gPassenger1Col). pad->tileY is the contact-surface
// row; the fare's feet sit on it, so the sprite top-left is two tiles up.
static void placeOnPad(StPassengerT *p, const StLevelT *level, uint8_t padIdx) {
const StPadT *pad = &level->pads[padIdx];
gNextFareIdx = 0u;
gWalkTick = 0u;
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
game->passengers[i].active = false;
}
// Seed the first fare for the level. Subsequent fares spawn on
// successful drop-off via spawnNextFare inside stPassengerTick.
spawnNextFare(game);
p->currentPad = padIdx;
p->x = (int16_t)pad->standX;
p->y = (int16_t)((pad->tileY - ST_PASSENGER_H_TILES) * ST_TILE_PIXELS);
}
void stPassengerTick(StGameT *game) {
StPassengerT *p;
StTaxiT *t = &game->taxi;
uint8_t i;
bool atDest;
// Small LCG -> uniform pad index in [0, n-1]. n==0 guarded to 0.
static uint8_t rngPad(uint8_t n) {
if (n == 0u) {
return 0u;
}
gRng = gRng * 1103515245u + 12345u;
return (uint8_t)(((gRng >> 16) & 0x7FFFu) % n);
}
// Walk-cel cycling: advance every ST_WALK_TICKS_PER_CEL host frames
// for all waiting passengers in lockstep. Also step horizontally
// one pixel per cel advance so passengers walk back-and-forth
// across their pad. The C64 game's passenger AI was never fully
// traced -- this is a simplification giving each waiting fare
// some idle motion instead of standing in place.
if (++gWalkTick >= ST_WALK_TICKS_PER_CEL) {
gWalkTick = 0u;
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
StPassengerT *p = &game->passengers[i];
static void spawnPassenger(StGameT *game) {
StPassengerT *p = &game->passengers[0];
uint8_t padIdx;
if (game->level.padCount == 0u) {
return;
}
padIdx = rngPad(game->level.padCount);
p->active = true;
p->onboard = false;
p->walkPhase = 0u;
p->walkDir = 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;
if (!p->active || p->onboard) {
continue;
}
p->walkPhase = (uint8_t)((p->walkPhase + 1u) % ST_WALK_FRAMES);
if (p->currentPad >= game->level.padCount) {
continue;
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);
padXMax = (int16_t)((pad->tileX + pad->tileW) * ST_TILE_PIXELS - ST_PASSENGER_W_PX);
if (padXMax <= padXMin) {
continue; // pad too narrow to walk on
return; // pad too narrow to walk on
}
if (p->walkDir == 0u) {
p->x++;
@ -166,51 +166,76 @@ void stPassengerTick(StGameT *game) {
p->walkDir = 0u;
}
}
}
}
}
void stPassengerReset(StGameT *game) {
uint8_t i;
gWalkTick = 0u;
gDeliveredThisScreen = 0u;
gSpawnCountdown = ST_SPAWN_CAP_FRAMES;
// Deterministic per-screen seed: varies the pad sequence level to
// level without needing a wall-clock source.
gRng = 0x2545F491u ^ ((uint32_t)game->levelIndex * 2654435761u);
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
p = &game->passengers[i];
game->passengers[i].active = false;
}
// The screen starts empty; the first fare appears after the spawn
// cadence in stPassengerTick.
}
void stPassengerTick(StGameT *game) {
StPassengerT *p = &game->passengers[0];
StTaxiT *t = &game->taxi;
if (!p->active) {
continue;
// Idle: a fresh fare appears after the C64 spawn cadence. Never
// spawn mid-crash (the C64 gates the idle ticker on
// gCollisionPhase == 0).
if (t->crashTicks == 0u) {
if (gSpawnCountdown > 0) {
gSpawnCountdown--;
}
if (rngPad(100u) < ST_SPAWN_ROLL_THRESH || gSpawnCountdown <= 0) {
spawnPassenger(game);
}
}
return;
}
if (!p->onboard) {
// Board if the taxi has landed on their pad.
// Walk in place while waiting.
if (++gWalkTick >= ST_WALK_TICKS_PER_CEL) {
gWalkTick = 0u;
walkPassenger(game, p);
}
// Board when the taxi has landed on their pad.
if (t->landed && t->onPad == p->currentPad) {
p->onboard = true;
pickDestination(game, p);
stAudioSfxPickup();
}
} else {
// Onboard: deliver to destination pad.
if (p->destPad >= game->level.padCount) {
p->active = false;
continue;
}
atDest = t->landed &&
} else if (p->destPad != ST_DEST_TRANSPORTER) {
// Deliver by landing on the destination pad. (UP-PLEASE fares are
// delivered on the transporter fly-up; see stPassengerTransporterExit.)
if (t->landed &&
t->onPad < game->level.padCount &&
t->onPad == p->destPad;
if (atDest) {
game->score += ST_FARE_SCORE;
stAudioSfxDropoff();
p->active = false;
spawnNextFare(game);
}
}
}
// If every fare is exhausted AND no active passenger remains,
// the level is complete.
if (gNextFareIdx >= effectiveFareTarget(game)) {
bool anyActive = false;
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
if (game->passengers[i].active) {
anyActive = true;
break;
}
}
if (!anyActive) {
game->state = ST_STATE_LEVEL_DONE;
t->onPad == p->destPad) {
deliverFare(game, p);
}
}
}
void stPassengerTransporterExit(StGameT *game) {
StPassengerT *p = &game->passengers[0];
if (p->active && p->onboard && p->destPad == ST_DEST_TRANSPORTER) {
game->score += ST_FARE_SCORE;
stAudioSfxDropoff();
p->active = false;
}
}

View file

@ -70,6 +70,11 @@
#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
// Flame placeholder fallback (only used if real sprite-2 cel is
// missing): a small bright rectangle below the cab while thrusting.
@ -111,6 +116,7 @@ typedef struct {
uint8_t currentBankId; // which tbank%u.tbk is loaded (0xFF = none)
bool bankLoaded;
jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT];
jlSpriteT *warpCels[ST_WARP_CEL_COUNT];
jlSpriteT *flameCels[ST_FLAME_CEL_COUNT];
jlSpriteBackupT flameBackup;
uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES];
@ -268,8 +274,20 @@ typedef struct {
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 logoFlipIdx; // mirrors $48A5 (0..5 ping-pong index)
uint8_t logoColorIdx; // mirrors $48A4 (0..7 color cycle index)
} StTitleStateT;
// The single animated logo character. Its bitmap is flip-booked through
// the four frames that follow it in the charset ($85..$88).
#define ST_LOGO_CHAR 0x84u
static StTitleStateT gTitle;
static void titleReset(void);
@ -464,6 +482,63 @@ static void titleReset(void) {
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.
gTitle.logoFlipDiv = 3u;
}
// $4827 logo flip-book + $4861 color cycle. The title's "SPACE TAXI"
// logo is 103 cells of char $84 (verified in the title dump). Every 4th
// frame ($4827: INC $48A6 / AND #$03 / BEQ) the C64 copies one flip
// frame ($85..$88) over char $84's bitmap, walking the ping-pong table
// $48A7 = {1,2,3,4,3,2} -> frames $85,$86,$87,$88,$87,$86 so each dot
// rotates end over end; when the index reaches 4 it advances the 8-entry
// 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) {
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
const jlTileT *tile;
uint8_t ch;
uint8_t fg;
uint8_t bx;
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) {
return;
}
// 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;
if (tile != NULL) {
for (by = 0u; by < ST_PLAYFIELD_ROWS; by++) {
for (bx = 0u; bx < ST_TILEMAP_W; bx++) {
i = (size_t)by * ST_TILEMAP_W + bx;
if (level->tilemap[i] == ST_LOGO_CHAR) {
jlTilePasteMono(stage, bx, by, tile, fg, level->bgColor);
}
}
}
}
// $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);
}
}
@ -671,9 +746,10 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
char buf[32];
// Title screen: render the title tilemap (extracted from the C64
// game's screen RAM at $0400). The "SPACE TAXI" logo stays at
// its captured color -- the C64 ran a rainbow cycle here, but
// we keep it static so the per-frame logo repaint cost is zero.
// game's screen RAM at $0400). The "SPACE TAXI" logo is animated by
// titleAnimateLogo below -- the C64 flip-books char $84's bitmap
// ($4827) so each dot rotates end over end while an 8-color cycle
// ($4861) runs; both were missing before.
if (game->state == ST_STATE_TITLE) {
// Restore the area saved under the prev-frame sprites BEFORE
// anything else paints. Without this, jlSpriteDraw leaves the
@ -701,6 +777,10 @@ 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);
// 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
@ -775,6 +855,68 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
jlStagePresent();
return;
}
if (game->state == ST_STATE_HIGHSCORES) {
// "THE IMMORTAL CABBIES" high-score screen ($4C1F). Header +
// the game's default 8-entry table, transcribed from $4A89
// (score string + 15-char name). Persistent scoring isn't wired
// up, so we show the original default table.
static const char *kHighScores[8] = {
"3877.56 MICHAEL PLATE",
"1809.51 MICHAEL PLATE",
"1179.87 ANDREAS PLATE",
" 892.65 MICHAEL PLATE",
" 685.37 ANDREAS PLATE",
" 629.45 MICHAEL PLATE",
" 569.50 MICHAEL PLATE",
" 504.97 ANDREAS PLATE",
};
uint8_t i;
jlSurfaceClear(stage, 0u);
stRenderDrawText(stage, 10u, 1u, "THE IMMORTAL CABBIES");
for (i = 0u; i < 8u; i++) {
stRenderDrawText(stage, 9u, (uint8_t)(4u + i * 2u), kHighScores[i]);
}
stRenderDrawText(stage, 10u, 22u, "PRESS FIRE TO RETURN");
jlWaitVBL();
jlStagePresent();
return;
}
if (game->state == ST_STATE_INSTRUCTIONS) {
// Author/about screen ($556A, reached via DOWN). Text transcribed
// verbatim from $56D4-$57A6.
jlSurfaceClear(stage, 0u);
stRenderDrawText(stage, 1u, 3u, "THIS PROGRAM DEVELOPED AND WRITTEN BY");
stRenderDrawText(stage, 12u, 5u, "JOHN F. KUTCHER");
stRenderDrawText(stage, 15u, 7u, "11/21/65");
stRenderDrawText(stage, 4u, 10u, "CURRENTLY, AS OF JANUARY 1984,");
stRenderDrawText(stage, 5u, 12u, "HE IS ATTENDING JOHNS HOPKINS");
stRenderDrawText(stage, 6u, 14u, "UNIVERSITY IN BALTIMORE, MD");
stRenderDrawText(stage, 1u, 18u, "ALSO TRY RESCUE SQUAD BY JOHN KUTCHER");
stRenderDrawText(stage, 10u, 22u, "PRESS FIRE TO RETURN");
jlWaitVBL();
jlStagePresent();
return;
}
if (game->state == ST_STATE_OPTIONS) {
// "GAME VARIATIONS" menu ($5295). Text + positions transcribed
// from the C64: header (13,0), prompt (1,2), digits (28,2),
// instructions (5,4) and (19,5). The C64 recolors the selected
// digit via color RAM ($D86C + sel*2); we mark it with a
// highlight box behind the digit cell (row 2, col 28 + sel*2).
uint8_t sel = (uint8_t)((game->fareTarget - 1u) & 3u); // 0..3
int16_t hx = (int16_t)((28 + (int)sel * 2) * (int)ST_TILE_PIXELS);
jlSurfaceClear(stage, 0u);
jlFillRect(stage, hx, (int16_t)(2 * ST_TILE_PIXELS),
(int16_t)ST_TILE_PIXELS, (int16_t)ST_TILE_PIXELS, 6u);
stRenderDrawText(stage, 13u, 0u, "GAME VARIATIONS");
stRenderDrawText(stage, 1u, 2u, "SELECT NUMBER OF CABBIES:");
stRenderDrawText(stage, 28u, 2u, "1 2 3 4");
stRenderDrawText(stage, 5u, 4u, "USE JOYSTICK: LEFT OR RIGHT,");
stRenderDrawText(stage, 19u, 5u, "FIRE TO SELECT");
jlWaitVBL();
jlStagePresent();
return;
}
if (game->state == ST_STATE_GAME_OVER) {
jlSurfaceClear(stage, 0u);
stRenderDrawText(stage, 15u, 8u, "GAME OVER");
@ -828,7 +970,22 @@ void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
if (gRender.taxiCels[taxiCel] == NULL) {
taxiCel = 0u;
}
if (gRender.taxiCels[taxiCel] != NULL) {
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);
}
if (gRender.warpCels[wcel] != NULL) {
jlSpriteSaveAndDraw(stage, gRender.warpCels[wcel], 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.
jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py,
@ -946,6 +1103,9 @@ static bool loadSpriteSheet(void) {
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_PASSENGER_CEL_COUNT; i++) {
gRender.passengerCels[i] = cels[ST_SPRITE_PASS_FIRST + i];
}
@ -964,6 +1124,12 @@ static void destroySprites(void) {
gRender.taxiCels[i] = NULL;
}
}
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
if (gRender.warpCels[i] != NULL) {
jlSpriteDestroy(gRender.warpCels[i]);
gRender.warpCels[i] = NULL;
}
}
for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
if (gRender.passengerCels[i] != NULL) {
jlSpriteDestroy(gRender.passengerCels[i]);