// Space Taxi -- tile bank + sprite rendering.
//
// Loads native (.tbk / .spr) assets at startup via jlTileBankLoad
// and jlSpriteBankLoad. Tile bytes are per-target planar; sprite
// data is cross-target chunky 4bpp (the Phase 11 walker reads
// chunky and c2p's inline at draw time). Per-frame work: save-
// under for moving sprites, draw, restore-under next frame. The
// static tilemap is committed once per scene change.
//
// Source PNGs live in assets/ and are baked at build time by
// tools/assetbake/assetbake.py:
//   font.png         -> font.tbk     (1000-tile 40x25 glyph sheet)
//   tiles/tbankN.png -> tiles/tbankN.tbk (256-tile playfield bank)
//   sprites/sprites.png -> sprites/sprites.spr (9x3 grid of 3x3
//       24x24 cels: row 0 taxi, row 1 passenger, row 2 flame)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "spacetaxi.h"

// All STAXI example sources share the STAXI load segment so the
// IIgs binary's _ROOT bank stays under 64 KB. No-op on other ports.

// Tile bank files are per-level: each level's .dat references a
// numeric bank id, and the host loads tiles/tbankN.tbk on demand
// (cached so re-entry on the same id is free).
//
// Per JoeyLib convention: each app installs into its own subdir
// of bin/, with runtime assets under DATA/. The DOS binary cwd's
// to the app dir when launched, so these paths are relative.
// 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 "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

// Font sheet layout: 320x200 indexed PNG = 40x25 grid of 8x8 glyphs,
// 1000 tiles total. asciiMap[c] packs the (col, row) location for
// ASCII c into a uint16_t; jlDrawText looks the tile up and pastes
// it as a transparent-on-color-0 glyph.
#define ST_FONT_COLS          40
#define ST_FONT_ROWS          25
#define ST_FONT_TILES_MAX     (ST_FONT_COLS * ST_FONT_ROWS)

// Sprite sheet is 9 cols x 3 rows of 24x24 (= 3x3 tile) cels. Row 0
// is the taxi (4 cels used, rest blank), row 1 is the passenger (9
// 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  55    /* 11 cols x 5 rows: taxi, passenger, flame, warp, death */
#define ST_SPRITE_TAXI_FIRST  0
#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 (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 (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.
// $6D6A in the asm positions sprite 2 at (taxi_col - 2, taxi_row);
// the real cels are extracted at runtime as flameCels[0..7].
#define ST_FLAME_W_PX          8
#define ST_FLAME_H_PX          8
#define ST_FLAME_OFFSET_X_PX  ((ST_TAXI_W_PX / 2) - (ST_FLAME_W_PX / 2))
#define ST_FLAME_OFFSET_Y_PX  (ST_TAXI_H_PX - 2)

// 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 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
// cell). Cels:
//   0/1 = walk-LEFT alternation ($C4 / $C5) for the title walk
//   2-7 = boarding sequence ($C6 / $C7 / $C8 / $C9 / $CA / $CB) per
//         $67A6 incrementing gSpr1PtrShadow from $C6 toward $CC.
#define ST_PASSENGER_W_PX     24
#define ST_PASSENGER_H_PX     24
#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)


typedef struct {
    jlTileT          tiles[ST_TILE_BANK_MAX];
    bool             tileValid[ST_TILE_BANK_MAX];
    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       *deathCels[ST_DEATH_CEL_COUNT];
    jlSpriteT       *flameCels[ST_FLAME_CEL_COUNT];
    jlSpriteBackupT  flameBackup;
    uint8_t          flameBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
    bool             flameHasBackup;
    jlSpriteT       *passengerCels[ST_PASSENGER_CEL_COUNT];
    jlSurfaceT      *fontSurface; // glyph surface, built from font.tbk at load
    uint16_t         asciiMap[128];
    bool             fontReady;
    jlSpriteBackupT  taxiBackup;
    uint8_t          taxiBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
    jlSpriteBackupT  passengerBackup[ST_MAX_PASSENGERS];
    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
    // 386 in DOSBox and exposes tearing as the paint races the raster.
    bool             tilemapDirty;
    const StLevelT  *lastLevel;
} StRenderStateT;


static StRenderStateT gRender;


static bool loadTileBank(uint8_t bankId);
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);

// ---- Title intro + demo --------------------------------------------------
//
// Faithful to the C64 sequence (see disassembly $4A03 et al):
//
//   Stage 2 ($4A17): wait $5A=90 frames running $66B7 (sparkle/effect)
//                    before the passenger appears.
//   Stage 3 ($4A24): passenger sprite walks horizontally toward the cab,
//                    one tick per $715D countdown. $6D0D moves $7176
//                    (passenger col) +/- 2 each tick based on relative
//                    pad-hover X, toggling sprite cel via $7161 parity.
//                    Advances when passenger col == $28 (= 40, at cab).
//   Stage 4 ($4A45): JMP $67A6 (transition).
//   Stage 5 ($4A48): forces gInputDirMask = $01 (UP). Calls flameSpriteUpdate.
//                    Cab climbs until gTaxiRow < $14 = 20, then silences
//                    voice 3 and advances. This is the takeoff.
//   Stage 6+:        physicsTick now sources its input from the script at
//                    $0902 via $48F2. The recorded demo plays.
//
// Single cab, single passenger. The 7-sprite init at $4555-$456F just
// parks all hardware sprite slots at (col $AA, row $8C) so they don't
// flash garbage when the title is first shown.

// (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).
#define ST_TITLE_STAGE_SPARKLE    2u    // gDeathStage value, $4A17
#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_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.
// In C64 sprite-X coords. Visible col = sprite-X - 24.
#define ST_TITLE_PAD_HOVER_SX     0x28u
// Cab init from table $4994/$49AC: sprite-X=$28, sprite-Y=$84, ptr=$C1.
#define ST_TITLE_CAB_SX_INIT      0x28
#define ST_TITLE_CAB_SY_INIT      0x84
// Passenger init from table: sprite-X=$32 + frac=$01 -> X=$32+$100=306,
// sprite-Y=$84, ptr=$C7.
#define ST_TITLE_PASS_SX_INIT     ((int16_t)0x132)   // $32 + $100
#define ST_TITLE_PASS_SY_INIT     0x84
#define ST_TITLE_PASS_PTR_INIT    0xC7   // $49B4[1]: initial passenger sprite ptr
#define ST_TITLE_PASS_PTR_WALK_LO 0xC4   // $6D68: walk-LEFT cel A
#define ST_TITLE_PASS_PTR_WALK_HI 0xC5   // $6D69: walk-LEFT cel B
#define ST_TITLE_PASS_PTR_BOARD0  0xC6   // boarding sequence start
#define ST_TITLE_PASS_PTR_BOARDED 0xCC   // $67B8: advance when ptr == $CC

// $66D6 sparkle cel table -- cycled by $66B7 during stage 2.
// Each entry shown for $715E (= 3) frames; one full cycle = 12 frames.
// 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
// table at $6DB0. Entries with no flame (no input, or invalid combos
// like UP+DOWN / LEFT+RIGHT / four-way) return -1.
static const int8_t kFlameCelByDirMask[16] = {
    -1,  /* 0  no input        */
     0,  /* 1  UP              -> $D8 */
     1,  /* 2  DOWN            -> $D4 */
    -1,  /* 3  UP+DOWN invalid */
     2,  /* 4  LEFT            -> $D5 */
     3,  /* 5  UP+LEFT         -> $D2 */
     4,  /* 6  DOWN+LEFT       -> $D1 */
    -1,  /* 7                  */
     5,  /* 8  RIGHT           -> $D7 */
     6,  /* 9  UP+RIGHT        -> $D3 */
     7,  /* 10 DOWN+RIGHT      -> $D6 */
    -1, -1, -1, -1, -1
};
// Stage-5 advance gate: $4A5F CMP #$14 BCC $4A64. Cab row in sprite-Y
// coords; visible row = sprite-Y - 50, so $14 = 20 = visible row -30.
#define ST_TITLE_CAB_TAKEOFF_SY   0x14
// C64-to-visible conversion offsets (sprite hardware borders).
#define ST_SPRITE_X_OFFSET        24
#define ST_SPRITE_Y_OFFSET        50

typedef struct {
    uint8_t  stage;             // mirrors gDeathStage during intro
    uint8_t  introFrameCount;   // mirrors $473F (stage 2 countdown)
    uint8_t  decayReload;       // mirrors $715E
    uint8_t  decayTimer;        // mirrors $715D (countdown per tick)
    uint8_t  passengerCelParity;// mirrors $7161 (toggled each walk step)
    uint8_t  sparkleIdx;        // mirrors $716E (sparkle cel index 0..3)
    uint8_t  passengerPtr;      // mirrors gSpr1PtrShadow $7198
    uint8_t  cabPtr;            // mirrors gSpr0PtrShadow $7197
    // Sprite positions in C64 sprite-X / sprite-Y space (16-bit because
    // sprite-X is 9 bits when MSB latched). Convert with the OFFSET
    // constants above when rendering.
    int16_t  cabSx;
    int16_t  cabSy;
    int16_t  passengerSx;
    int16_t  passengerSy;
    bool     passengerVisible;
    bool     cabVisible;
    bool     flameVisible;      // sprite 2 from $6D6A flameSpriteUpdate
    uint8_t  flameDirMask;      // mirrors $716A passed into $6DB0,X
    // Demo (post-intro stage 6) playback state -- script from $0902.
    // 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 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
// the four frames that follow it in the charset ($85..$88).
#define ST_LOGO_CHAR  0x84u

static StTitleStateT gTitle;

static void    titleReset(void);
static void    titleTick(void);


// Public so stLevel.c can ask "give me the tile object for index N".
const jlTileT *stRenderTileForIndex(uint8_t tileIdx) {
    if (!gRender.tileValid[tileIdx]) {
        return NULL;
    }
    return &gRender.tiles[tileIdx];
}


// Default 16-entry palette: matches the C64 VIC-II color register
// order so that an asset extracted with the C64 palette (via the
// extractFromDump.py tool) renders with the right colors here. The
// JoeyLib value format is $0RGB (4 bits per channel, top nibble
// unused). Index 0 black, 1 white, then C64 standard order.
static const uint16_t kDefaultPalette[16] = {
    0x0000,  // 0  black
    0x0FFF,  // 1  white
    0x0833,  // 2  red
    0x06BB,  // 3  cyan
    0x0839,  // 4  purple
    0x05A4,  // 5  green
    0x0438,  // 6  blue
    0x0BC7,  // 7  yellow
    0x0852,  // 8  orange
    0x0540,  // 9  brown
    0x0B66,  // 10 light red
    0x0555,  // 11 dark gray
    0x0777,  // 12 mid gray
    0x09E8,  // 13 light green
    0x076C,  // 14 light blue
    0x09AA   // 15 light gray
};

// Tile-index range -> placeholder fill color (used when no tile bank
// is loaded). Matches the engine's index-range convention.
static uint8_t placeholderColorFor(uint8_t tileIdx, uint8_t bgColor) {
    // Used only when no tile bank is loaded -- debug-rendering. Slot
    // indices reference kDefaultPalette above.
    if (tileIdx == 0u)        { return bgColor; }
    if (tileIdx < 64u)        { return 6u; }     // walls -> blue
    if (tileIdx < 128u)       { return 5u; }     // pads  -> green
    return 14u;                                  // decor -> light blue
}


void stRenderInit(jlSurfaceT *stage) {
    memset(&gRender, 0, sizeof(gRender));
    gRender.taxiBackup.bytes  = gRender.taxiBackupMem;
    gRender.flameBackup.bytes = gRender.flameBackupMem;
    gRender.currentBankId     = 0xFFu;  // no bank loaded yet
    for (uint8_t i = 0u; i < ST_MAX_PASSENGERS; i++) {
        gRender.passengerBackup[i].bytes = gRender.passengerBackupMem[i];
    }

    // Install the default 16-color palette on slot 0 and route the
    // whole screen through it. Subsequent asset palettes can override
    // by writing into slot 1+ via jlPaletteSet / jlScbSetRange.
    jlPaletteSet(stage, 0u, kDefaultPalette);
    jlScbSetRange(stage, 0u, (uint16_t)(SURFACE_HEIGHT - 1u), 0u);

    // 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();
}


void stRenderShutdown(void) {
    destroySprites();
    if (gRender.fontSurface != NULL) {
        jlSurfaceDestroy(gRender.fontSurface);
        gRender.fontSurface = NULL;
    }
}


void stRenderDrawText(jlSurfaceT *stage, uint8_t bx, uint8_t by, const char *s) {
    if (!gRender.fontReady || gRender.fontSurface == NULL || s == NULL) {
        return;
    }
    jlDrawText(stage, bx, by, gRender.fontSurface, gRender.asciiMap, s);
}


void stRenderLevel(jlSurfaceT *stage, const StLevelT *level) {
    const jlTileT *tile;
    uint8_t        bx;
    uint8_t        by;
    uint8_t        tileIdx;
    size_t         i;

    // Swap to the level's tile bank if it's not already loaded.
    // First call lands here and pulls tbank<id>.tbk off disk.
    if (!gRender.bankLoaded || gRender.currentBankId != level->tileBankId) {
        (void)loadTileBank(level->tileBankId);
        gRender.tilemapDirty = true;
    }
    // Scene change detected -> force a repaint. Otherwise this is a
    // no-op (the level tilemap data is static between calls).
    if (gRender.lastLevel != level) {
        gRender.tilemapDirty = true;
        gRender.lastLevel    = level;
    }
    if (!gRender.tilemapDirty) {
        return;
    }
    gRender.tilemapDirty = false;

    jlSurfaceClear(stage, level->bgColor);

    for (by = 0u; by < ST_PLAYFIELD_ROWS; by++) {
        for (bx = 0u; bx < ST_TILEMAP_W; bx++) {
            i       = (size_t)by * ST_TILEMAP_W + bx;
            tileIdx = level->tilemap[i];
            // tileIdx is uint8_t, naturally bounded to ST_TILE_BANK_MAX=256.
            tile    = gRender.tileValid[tileIdx] ? &gRender.tiles[tileIdx] : NULL;
            if (tile != NULL) {
                // C64-style per-cell coloring: tile bitmap is a fixed
                // glyph from the charset, colormap[i] is the foreground
                // color from color RAM ($D800). Empty tile (idx 0)
                // still goes through here as bgColor on both fg/bg --
                // jlTileFill is left for the no-bank-loaded fallback.
                uint8_t fg = (uint8_t)(level->colormap[i] & 0x0Fu);
                jlTilePasteMono(stage, bx, by, tile, fg, level->bgColor);
            } else {
                jlTileFill(stage, bx, by, placeholderColorFor(tileIdx, level->bgColor));
            }
        }
    }
}


void stRenderLevelChanged(void) {
    // Force the next stRenderLevel call to repaint. Needed because the
    // dirty-cache compares lastLevel by pointer, but stLevelLoad
    // overwrites *game.level in place -- the pointer stays equal and
    // the cache would otherwise skip the new tilemap.
    gRender.tilemapDirty = true;
    gRender.lastLevel    = NULL;
}



// $4A03 dispatcher entry state, from $47C9 postInitPressFire:
//   $47C7: A9 02      LDA #$02
//   $47C9: 8D 63 71   STA $7163        ; gDeathStage = 2
//   $47CC: A9 5A      LDA #$5A
//   $47CE: 8D 3F 47   STA $473F        ; gIntroFrameCount
//   $47D1: A9 00      LDA #$00
//   $47D3: 8D 60 71   STA $7160        ; gPadHoverXFrac
//   $47D6: A9 28      LDA #$28
//   $47D8: 8D 5F 71   STA $715F        ; gPadHoverXCol
//   $47DB: AD 5E 71   LDA $715E        ; decayReload (snapshot $03)
//   $47DE: 8D 5D 71   STA $715D        ; decayTimer
// Sprite positions from the $49BC table-loop, X=0..7:
//   $4994 col[]   = 28 32 00 86 9C A2 BA D4
//   $499C frac[]  = 00 01 00 00 00 00 00 00
//   $49AC row[]   = 84 84 00 E2 E2 E2 E2 E2
//   $49B4 ptr[]   = C1 C7 00 DB DE DF E0 E1
// Sprite 0 (cab) sprite-X = $28 = 40, sprite-Y = $84, ptr $C1.
// Sprite 1 (passenger) sprite-X = $32 + $100 = 306, sprite-Y = $84,
//   ptr $C7. (frac=1 latches the X-MSB.)
static void titleReset(void) {
    memset(&gTitle, 0, sizeof(gTitle));
    gTitle.stage              = ST_TITLE_STAGE_SPARKLE;
    gTitle.introFrameCount    = ST_TITLE_SPARKLE_FRAMES;
    gTitle.decayReload        = ST_TITLE_WALK_TICK_RELOAD;
    gTitle.decayTimer         = ST_TITLE_WALK_TICK_RELOAD;
    gTitle.passengerCelParity = 0u;
    gTitle.passengerPtr       = ST_TITLE_PASS_PTR_INIT;
    gTitle.cabPtr             = 0xC1;     // sprite 0 init ptr
    gTitle.cabSx              = ST_TITLE_CAB_SX_INIT;
    gTitle.cabSy              = ST_TITLE_CAB_SY_INIT;
    gTitle.passengerSx        = ST_TITLE_PASS_SX_INIT;
    gTitle.passengerSy        = ST_TITLE_PASS_SY_INIT;
    gTitle.cabVisible         = true;
    gTitle.passengerVisible   = true;
    gTitle.flameVisible       = false;
    // 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;
}


// $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.
// 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
    const jlTileT *tile;
    uint8_t        ch;
    uint8_t        fg;
    uint8_t        bx;
    uint8_t        by;
    size_t         i;

    if (!gTitle.logoNeedsPaint) {
        return;
    }
    gTitle.logoNeedsPaint = false;

    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);
                }
            }
        }
    }
}


// $6D0D passenger-walk: toggle $7161 cel parity, compute sign of
// (passenger_X - pad_hover_X), branch to walk-LEFT or walk-RIGHT
// helper. Both load gSpr1PtrShadow from a 2-entry ptr table indexed
// by the cel-parity bit, then advance the column by +/- 2.
//   $6D30  LDX $7161 / LDA $6D66,X / STA $7198    (walk RIGHT)
//   $6D33: $6D66 = $C2, $6D67 = $C3
//   $6D4B  LDX $7161 / LDA $6D68,X / STA $7198    (walk LEFT)
//   $6D4E: $6D68 = $C4, $6D69 = $C5
// In the title intro the passenger starts at sprite-X 306 and walks
// LEFT toward $28, so the LEFT cel table ($C4 / $C5) is the one in
// use. We track the live ptr in gTitle.passengerPtr so the boarding
// stage can keep incrementing from wherever the walk left off.
static void titleStepPassenger(void) {
    gTitle.passengerCelParity ^= 1u;
    if (gTitle.passengerSx > (int16_t)ST_TITLE_PAD_HOVER_SX) {
        gTitle.passengerSx -= 2;
        gTitle.passengerPtr = (gTitle.passengerCelParity & 1u)
                            ? ST_TITLE_PASS_PTR_WALK_HI
                            : ST_TITLE_PASS_PTR_WALK_LO;
    } else if (gTitle.passengerSx < (int16_t)ST_TITLE_PAD_HOVER_SX) {
        gTitle.passengerSx += 2;
        // Walk-RIGHT path uses $C2/$C3; not used by the title, but
        // include it for completeness so any side-entry passenger
        // would render correctly.
        gTitle.passengerPtr = (gTitle.passengerCelParity & 1u)
                            ? 0xC3
                            : 0xC2;
    }
}


// $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.
        // $66B7 logic (per the asm):
        //   DEC $715D                ; gIntroDecayTimer
        //   BEQ $66BD                ; hit-zero -> cycle a cel
        //   RTS                      ; else keep current cel
        //   $66BD: LDA $715E STA $715D    ; reload from gIntroDecayReload
        //          INC $716E              ; sparkleIdx
        //          LDA $716E AND #$03 STA $716E   ; mask 0..3
        //          TAX
        //          LDA $66D6,X STA $7198  ; passenger ptr = sparkle table
        // So the passenger ptr cycles $C6 / $C7 / $D9 / $C7 every reload
        // frames during the 90-frame sparkle wait, AND the decay timer
        // is left in the middle of its countdown when stage 3 takes
        // over -- which is why the walk starts after 0-2 frames, not
        // the full 3-frame reload.
        if (gTitle.decayTimer > 0u) {
            gTitle.decayTimer--;
        }
        if (gTitle.decayTimer == 0u) {
            gTitle.decayTimer = gTitle.decayReload;
            gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u);
            gTitle.passengerPtr = kPassengerWaveCels[gTitle.sparkleIdx];
        }
        if (gTitle.introFrameCount > 0u) {
            gTitle.introFrameCount--;
        }
        if (gTitle.introFrameCount == 0u) {
            gTitle.stage = ST_TITLE_STAGE_WALK;
        }
        break;

    case ST_TITLE_STAGE_WALK:
        // $4A24: DEC $715D / BEQ -> reload from $715E + JSR $6D0D /
        // then check (passenger col == $28) && ($7186 == 0) -> advance.
        if (gTitle.decayTimer > 0u) {
            gTitle.decayTimer--;
        }
        if (gTitle.decayTimer == 0u) {
            gTitle.decayTimer = gTitle.decayReload;
            titleStepPassenger();
            if (gTitle.passengerSx == (int16_t)ST_TITLE_PAD_HOVER_SX) {
                gTitle.stage = ST_TITLE_STAGE_HANDOFF;
            }
        }
        break;

    case ST_TITLE_STAGE_HANDOFF:
        // $4A45: JMP $67A6.
        // $67A6: DEC $715D / BEQ -> reload + INC $7198 (passenger
        // sprite cel ptr). Advance when ptr == $CC.
        if (gTitle.decayTimer > 0u) {
            gTitle.decayTimer--;
        }
        if (gTitle.decayTimer == 0u) {
            gTitle.decayTimer = gTitle.decayReload;
            gTitle.passengerPtr++;
            if (gTitle.passengerPtr >= ST_TITLE_PASS_PTR_BOARDED) {
                // $67BD: STA $718F = 0 (pad hover off); INC $7163 to 5.
                gTitle.passengerVisible = false;
                gTitle.stage            = ST_TITLE_STAGE_LIFTOFF;
            }
        }
        break;

    case ST_TITLE_STAGE_LIFTOFF:
        // $4A48: LDX #0; LDA #$FE; JSR $4113 (gTaxiRow[0] += -2).
        //        LDA #$01; STA gInputDirMask. LDA #$C0; STA gSpr0Ptr.
        //        JSR $6D6A (flame sprite update -- sprite 2 visible).
        //        LDA gTaxiRow[0]; CMP #$14; BCC $4A64.
        gTitle.cabSy   -= 2;
        gTitle.cabPtr   = 0xC0;
        gTitle.flameDirMask = 0x01;        // UP only
        gTitle.flameVisible = true;
        if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) {
            // $4A64-$4A6C: INC gDeathStage; silence voice 3.
            gTitle.cabVisible   = false;
            gTitle.flameVisible = false;
            // $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_DONE:
    default:
        // Intro finished -- the title art sits static (logo cycle
        // continues); spacetaxi.c switches to ST_STATE_DEMO.
        break;
    }
}


void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
    int16_t  px;
    int16_t  py;
    uint8_t  taxiCel;
    uint8_t  i;
    char     buf[32];

    // Title screen: render the title tilemap (extracted from the C64
    // 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
        // old sprite pixels on the stage and the cab/passenger smear
        // across the screen as they move ("5 cabs then erase" was the
        // titleCycleTick periodic tilemap re-blit fighting the
        // accumulated trails).
        // C64 sprite priority: sprite 0 (cab) > 1 (passenger) > 2 (flame).
        // Draw order each frame: flame, passenger, cab (lowest priority
        // first). Restore is the reverse: cab, passenger, flame -- so
        // overlapping saved underlays peel off in the right sequence.
        if (gRender.taxiHasBackup) {
            jlSpriteRestoreUnder(stage, &gRender.taxiBackup);
            gRender.taxiHasBackup = false;
        }
        if (gRender.passengerHasBackup[0]) {
            jlSpriteRestoreUnder(stage, &gRender.passengerBackup[0]);
            gRender.passengerHasBackup[0] = false;
        }
        if (gRender.flameHasBackup) {
            jlSpriteRestoreUnder(stage, &gRender.flameBackup);
            gRender.flameHasBackup = false;
        }
        // stRenderLevel only re-paints the tilemap when lastLevel changed
        // (e.g., transitioned from gameplay back to title) -- otherwise
        // it's a fast no-op.
        stRenderLevel(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
        // with the selected digit highlighted in color RAM at $D86C).
        // The main title's screen RAM at $0400 contains no game-option
        // 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.
        // 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 && flameStrobeOn() &&
            gTitle.flameDirMask < 16u) {
            int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask];
            if (cel >= 0 && gRender.flameCels[cel] != NULL) {
                // $6D74-$6D82: flame sprite-X = cab sprite-X - 2.
                // $6D85-$6D88: flame sprite-Y = cab sprite-Y.
                int16_t fx = (int16_t)(gTitle.cabSx - 2 - ST_SPRITE_X_OFFSET);
                int16_t fy = (int16_t)(gTitle.cabSy - ST_SPRITE_Y_OFFSET);
                jlSpriteSaveAndDraw(stage, gRender.flameCels[cel],
                                    fx, fy, &gRender.flameBackup);
                gRender.flameHasBackup = true;
            }
        }
        if (gTitle.passengerVisible) {
            int16_t  cel = passCelForPtr(gTitle.passengerPtr);
            if (cel < 0) {
                cel = 0;
            }
            if (cel >= ST_PASSENGER_CEL_COUNT) {
                cel = ST_PASSENGER_CEL_COUNT - 1;
            }
            if (gRender.passengerCels[cel] != NULL) {
                jlSpriteSaveAndDraw(stage, gRender.passengerCels[cel],
                                    (int16_t)(gTitle.passengerSx - ST_SPRITE_X_OFFSET),
                                    (int16_t)(gTitle.passengerSy - ST_SPRITE_Y_OFFSET),
                                    &gRender.passengerBackup[0]);
                gRender.passengerHasBackup[0] = true;
            }
        }
        if (gTitle.cabVisible) {
            // $4A54 sets gSpr0PtrShadow = $C0 (cab with landing gear
            // RETRACTED) during takeoff. Stages 2-4 use the init ptr
            // $C1 (gear extended). We track the live ptr in
            // gTitle.cabPtr and map: $C1 -> cel 0, $C0 -> cel 1.
            int16_t  cel = (gTitle.cabPtr == 0xC0) ? 1 : 0;
            if (gRender.taxiCels[cel] == NULL) {
                cel = 0;
            }
            if (gRender.taxiCels[cel] != NULL) {
                jlSpriteSaveAndDraw(stage, gRender.taxiCels[cel],
                                    (int16_t)(gTitle.cabSx - ST_SPRITE_X_OFFSET),
                                    (int16_t)(gTitle.cabSy - ST_SPRITE_Y_OFFSET),
                                    &gRender.taxiBackup);
                gRender.taxiHasBackup = true;
            }
        }
        jlWaitVBL();
        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");
        snprintf(buf, sizeof(buf), "FINAL SCORE %06lu",
                 (unsigned long)game->score);
        stRenderDrawText(stage, 12u, 11u, buf);
        stRenderDrawText(stage,  9u, 16u, "PRESS SPACE TO RESTART");
        jlWaitVBL();
        jlStagePresent();
        return;
    }

    // Restore prev-frame sprite backups FIRST so the static tilemap
    // 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]);
            gRender.passengerHasBackup[i] = false;
        }
    }
    // Static tilemap commit (no-op after first frame in a scene).
    stRenderLevel(stage, &game->level);

    // Re-draw HUD strip (cheap; just a few characters per cycle).
    stHudDraw(stage, game);

    px = (int16_t)(game->taxi.x >> ST_SUBPIXEL_SHIFT);
    py = (int16_t)(game->taxi.y >> ST_SUBPIXEL_SHIFT);

    // $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 = (uint8_t)(game->taxi.gearDown ? 0u : 1u);
    }
    if (gRender.taxiCels[taxiCel] == NULL) {
        taxiCel = 0u;
    }
    // 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 (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;
    } else {
        // No sprite asset: fall back to a placeholder rect AND mark
        // the tilemap dirty so the level repaints over us next frame.
        // Use the level's per-level sprite0Color ($D027) so the cab
        // tint matches the level palette.
        jlFillRect(stage, px, py, ST_TAXI_W_PX, ST_TAXI_H_PX,
                   game->level.sprite0Color);
        gRender.tilemapDirty = true;
        if (game->taxi.thrusting) {
            jlFillRect(stage,
                       (int16_t)(px + ST_FLAME_OFFSET_X_PX),
                       (int16_t)(py + ST_FLAME_OFFSET_Y_PX),
                       ST_FLAME_W_PX,
                       ST_FLAME_H_PX,
                       game->level.sprite1Color);
        }
    }

    // Passengers (waiting or being carried)
    for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
        const StPassengerT *p = &game->passengers[i];
        jlSpriteT *cel;
        int16_t    pcel;
        if (!p->active || p->phase == ST_PASS_RIDING) {
            continue;       // riding passengers are invisible in the cab
        }
        // 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);
            gRender.tilemapDirty = true;
            continue;
        }
        jlSpriteSaveAndDraw(stage, cel, p->x, p->y,
                            &gRender.passengerBackup[i]);
        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,
                   (int16_t)(2 * ST_TILE_PIXELS), 0u);
        stRenderDrawText(stage, 13u, 10u, "LEVEL COMPLETE");
    }

    jlWaitVBL();
    jlStagePresent();
}


// ----- internal helpers -----

static bool loadTileBank(uint8_t bankId) {
    uint16_t  idx;
    uint16_t  loaded;
    char      path[64];

    snprintf(path, sizeof(path), ST_TILE_BANK_PATH_FMT, (unsigned)bankId);
    jlLogF("stRender: loadTileBank(%u) -> %s", (unsigned)bankId, path);

    // Reset the previous bank's validity bits so a smaller new bank
    // doesn't inherit stale tiles past its end.
    for (idx = 0u; idx < ST_TILE_BANK_MAX; idx++) {
        gRender.tileValid[idx] = false;
    }
    gRender.bankLoaded     = false;
    gRender.currentBankId  = bankId;

    // Native bake: jlTileBankLoad fread's per-target planar tile
    // bytes straight into gRender.tiles[].pixels with no chunky <->
    // planar conversion. Replaces the JAS-load + jlSurfaceCreate +
    // jlSurfaceBlit + per-tile jlTileSnap path, which was the
    // dominant startup cost.
    loaded = jlTileBankLoad(path, gRender.tiles, ST_TILE_BANK_MAX,
                            gRender.tileValid, NULL);
    if (loaded == 0u) {
        jlLogF("stRender: ! tile bank load failed (%s)", path);
        return false;
    }
    jlLogF("stRender:   tile bank loaded (%u tiles)", (unsigned)loaded);
    gRender.bankLoaded = true;
    return true;
}


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 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, 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, %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;
}


static void destroySprites(void) {
    uint8_t i;
    for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
        if (gRender.taxiCels[i] != NULL) {
            jlSpriteDestroy(gRender.taxiCels[i]);
            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]);
            gRender.warpCels[i] = NULL;
        }
    }
    for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
        if (gRender.passengerCels[i] != NULL) {
            jlSpriteDestroy(gRender.passengerCels[i]);
            gRender.passengerCels[i] = NULL;
        }
    }
}


// The 1000-glyph font streams straight onto the font surface via
// jlTileBankLoadToSurface -- no 32KB staging buffer. The old
// malloc(32000) was lethal on IIgs (the clang libc heap is ~665
// bytes and malloc returns 1, not NULL, at exhaustion -- LLVM816-ASKS
// item 4 -- so jlTileBankLoad fread the payload through pointer 1 and
// shredded bank 0 before the first present), and a 32KB static blows
// the bank-0 BSS ceiling. The streaming loader exists for exactly
// this case; its block grid matches ST_FONT_COLS row-major layout.
static bool loadFontSheet(jlSurfaceT *stage) {
    uint16_t count;
    uint16_t palette[16];

    (void)stage;
    gRender.fontSurface = jlSurfaceCreate();
    if (gRender.fontSurface == NULL) {
        return false;
    }
    count = jlTileBankLoadToSurface(ST_FONT_PATH, gRender.fontSurface, palette);
    if (count == 0u) {
        jlSurfaceDestroy(gRender.fontSurface);
        gRender.fontSurface = NULL;
        return false;
    }
    // Font palette is authoritative for the font surface so jlDrawText
    // reads back the authored colors. Stage palette is untouched.
    jlPaletteSet(gRender.fontSurface, 0u, palette);
    jlScbSetRange(gRender.fontSurface, 0, SURFACE_HEIGHT - 1, 0);
    gRender.fontReady = true;
    return true;
}


// 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),
// so the map is a direct computation. Control characters and DEL
// (0..31, 127) are marked TILE_NO_GLYPH so jlDrawText skips them.
static void buildAsciiMap(void) {
    uint16_t  i;
    for (i = 0u; i < 128u; i++) {
        if (i < 32u || i == 127u) {
            gRender.asciiMap[i] = TILE_NO_GLYPH;
        } else {
            uint16_t col = (uint16_t)(i % ST_FONT_COLS);
            uint16_t row = (uint16_t)(i / ST_FONT_COLS);
            gRender.asciiMap[i] = (uint16_t)(col | (row << 8));
        }
    }
}
