joeylib2/examples/spacetaxi/stRender.c

1033 lines
44 KiB
C

// 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.
JOEYLIB_SEGMENT("STAXI")
// 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 "DATA/tiles/tbank%u.tbk"
#define ST_SPRITE_SHEET_PATH "DATA/sprites/sprites.spr"
#define ST_FONT_PATH "DATA/font.tbk"
#define ST_TILE_BANK_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 27
#define ST_SPRITE_TAXI_FIRST 0
#define ST_SPRITE_PASS_FIRST 9
#define ST_SPRITE_FLAME_FIRST 18
// Taxi sprite: 24x24 px = 3 tiles wide x 3 tiles tall. The port's
// sprite asset (genPlaceholderArt.py) authors 4 cels:
// cel 0: idle cab (no thrust)
// cel 1: thrust frame A
// cel 2: thrust frame B
// cel 3: thrust frame C
// Index: thrusting ? (1 + thrustFrame/ST_THRUST_CEL_TICKS) : 0. The
// C64's $619B selects between LEFT-facing ($DC) and RIGHT-facing
// ($C0) cab bases with a 1-bit flicker on each, but the port's asset
// doesn't carry left/right variants -- left/right facing isn't
// visually reflected until the sheet adds them.
#define ST_TAXI_W_PX 24
#define ST_TAXI_H_PX 24
#define ST_TAXI_CEL_COUNT 4
// 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 frame.
#define ST_FLAME_CEL_W_PX 24
#define ST_FLAME_CEL_H_PX 24
#define ST_FLAME_CEL_COUNT 8
// 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 9
#define ST_PASSENGER_CEL_WALK0 0
#define ST_PASSENGER_CEL_WALK1 1
#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_SPRITE_BACKUP_BYTES (((ST_TAXI_W_PX >> 1) + 4) * ST_TAXI_H_PX)
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 *flameCels[ST_FLAME_CEL_COUNT];
jlSpriteBackupT flameBackup;
uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES];
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];
jlSpriteBackupT passengerBackup[ST_MAX_PASSENGERS];
uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES];
bool taxiHasBackup;
bool passengerHasBackup[ST_MAX_PASSENGERS];
// 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(void);
static bool loadFontSheet(jlSurfaceT *stage);
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.
// Recorded (input_mask, frame_duration) pairs at C64 $0902. Played by
// $48F2 once the title intro reaches the demo phase.
static const uint8_t kTitleDemoScript[256] = {
0x80, 0x17, 0x88, 0x0A, 0x80, 0x0A, 0x84, 0x04, 0x85, 0x03, 0x81, 0x03, 0x80, 0x0A, 0x88, 0x04,
0x80, 0x01, 0x81, 0x0E, 0x80, 0x0D, 0x82, 0x02, 0x80, 0x0A, 0x81, 0x09, 0x85, 0x07, 0x84, 0x05,
0x80, 0x02, 0x88, 0x05, 0x8A, 0x04, 0x82, 0x03, 0x80, 0x05, 0x85, 0x10, 0x81, 0x08, 0x89, 0x02,
0x88, 0x0E, 0x8A, 0x03, 0x88, 0x06, 0x81, 0x07, 0x80, 0x12, 0x82, 0x05, 0x80, 0x04, 0x81, 0x11,
0x80, 0x04, 0x82, 0x04, 0x86, 0x09, 0x84, 0x02, 0x95, 0x07, 0x85, 0x04, 0x81, 0x01, 0x80, 0x43,
0x81, 0x0A, 0x84, 0x05, 0x86, 0x03, 0x82, 0x06, 0x80, 0x07, 0x81, 0x09, 0x80, 0x04, 0x88, 0x0B,
0x80, 0x09, 0x84, 0x04, 0x80, 0x09, 0x81, 0x01, 0x89, 0x04, 0x80, 0x08, 0x85, 0x04, 0x81, 0x01,
0x80, 0x05, 0x81, 0x15, 0x80, 0x0C, 0x82, 0x02, 0x86, 0x07, 0x80, 0x05, 0x81, 0x09, 0x80, 0x13,
0x84, 0x01, 0x85, 0x03, 0x80, 0x06, 0x88, 0x0B, 0x80, 0x16, 0x81, 0x0A, 0x80, 0x01, 0x88, 0x04,
0x80, 0x0C, 0x81, 0x03, 0x85, 0x02, 0x84, 0x03, 0x85, 0x0C, 0x81, 0x01, 0x80, 0x0D, 0x82, 0x07,
0x80, 0x06, 0x81, 0x02, 0x89, 0x0C, 0x88, 0x02, 0x80, 0x05, 0x82, 0x04, 0x80, 0x0A, 0x81, 0x04,
0x85, 0x01, 0x84, 0x04, 0x80, 0x0D, 0x81, 0x05, 0x89, 0x0E, 0x81, 0x01, 0x80, 0x11, 0x84, 0x17,
0x80, 0x05, 0x81, 0x04, 0x80, 0x19, 0x81, 0x03, 0x89, 0x01, 0x88, 0x0E, 0x80, 0x07, 0x84, 0x05,
0x80, 0x0C, 0x81, 0x0D, 0x80, 0x19, 0x88, 0x01, 0x80, 0x02, 0x88, 0x04, 0x80, 0x1B, 0x88, 0x0A,
0x89, 0x0B, 0x81, 0x14, 0x80, 0x02, 0x82, 0x03, 0x80, 0x05, 0x84, 0x03, 0x85, 0x09, 0x84, 0x05,
0x85, 0x05, 0x84, 0x02, 0x86, 0x08, 0x84, 0x01, 0x80, 0x01, 0x81, 0x03, 0x89, 0x12, 0x81, 0x08,
};
// 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_DEMO 6u // intro over; demo physics
#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.
static const uint8_t kTitleSparkleCels[4] = { 0xC6, 0xC7, 0xD9, 0xC7 };
// 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
uint8_t flameParity; // mirrors $716C; flickers flame off when 0
// Demo (post-intro stage 6) playback state -- script from $0902.
uint16_t scriptIdx;
uint8_t scriptTimer;
int32_t demoX; // ST_SUBPIXEL fixed sub-pixel x
int32_t demoY;
int16_t demoVx;
int16_t demoVy;
} StTitleStateT;
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);
// Tile bank is loaded on demand in stRenderLevel (per-level).
if (!loadSpriteSheet()) {
jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH);
}
if (!loadFontSheet(stage)) {
jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH);
}
buildAsciiMap();
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;
// Demo state: initial script pos / pair-0 duration preload.
gTitle.scriptIdx = 0u;
gTitle.scriptTimer = kTitleDemoScript[1];
gTitle.demoX = (int32_t)ST_TITLE_CAB_SX_INIT * ST_SUBPIXEL;
gTitle.demoY = (int32_t)ST_TITLE_CAB_SY_INIT * ST_SUBPIXEL;
}
// $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) {
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 = kTitleSparkleCels[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;
gTitle.flameParity ^= 1u; // $6D8B-$6D90 toggle on every call
if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) {
// $4A64-$4A6C: INC gDeathStage; silence voice 3.
gTitle.cabVisible = false;
gTitle.flameVisible = false;
gTitle.stage = ST_TITLE_STAGE_DEMO;
// Demo continues from wherever the cab is -- the C64 does
// NOT reset the cab position. Physics + gravity bring it
// back into view from the top.
gTitle.demoX = (int32_t)gTitle.cabSx * ST_SUBPIXEL;
gTitle.demoY = (int32_t)gTitle.cabSy * ST_SUBPIXEL;
gTitle.demoVx = 0;
gTitle.demoVy = (int16_t)(-2 * ST_SUBPIXEL); // takeoff momentum
}
break;
case ST_TITLE_STAGE_DEMO:
default:
{
// Demo phase = physicsTick with $48F2 sourcing input from
// kTitleDemoScript. $48F2 advance pattern at $4903-$4923:
// DEC $4740; BNE return; -- still on current pair
// LDY #$03; LDA ($3F),Y; -- new timer from NEXT pair
// STA $4740;
// $3F += 2; -- advance to next pair
uint8_t mask;
int8_t dx;
int8_t dy;
int32_t maxX = (int32_t)(SURFACE_WIDTH - ST_TAXI_W_PX) * ST_SUBPIXEL;
int32_t maxY = (int32_t)(SURFACE_HEIGHT - ST_TAXI_H_PX) * ST_SUBPIXEL;
if (gTitle.scriptTimer > 0u) {
gTitle.scriptTimer--;
}
if (gTitle.scriptTimer == 0u) {
gTitle.scriptIdx = (uint16_t)((gTitle.scriptIdx + 2u) & 0xFFu);
gTitle.scriptTimer =
kTitleDemoScript[(gTitle.scriptIdx + 1u) & 0xFFu];
if (gTitle.scriptTimer == 0u) {
gTitle.scriptTimer = 1u;
}
}
mask = kTitleDemoScript[gTitle.scriptIdx & 0xFFu];
// CIA1 PortA bits (after EOR #$FF at $6043):
// bit0 = UP, bit1 = DOWN, bit2 = LEFT, bit3 = RIGHT, bit4 = FIRE.
dx = (int8_t)(((mask & 0x08u) ? 1 : 0) - ((mask & 0x04u) ? 1 : 0));
dy = (int8_t)(((mask & 0x02u) ? 1 : 0) - ((mask & 0x01u) ? 1 : 0));
// Level-A defaults for the demo: xAccel/yAccel = 14, yGrav = 1.
gTitle.demoVx = (int16_t)(gTitle.demoVx + dx * 14);
gTitle.demoVy = (int16_t)(gTitle.demoVy + dy * 14 + 1);
if (gTitle.demoVx > 512) gTitle.demoVx = 512;
if (gTitle.demoVx < -512) gTitle.demoVx = -512;
if (gTitle.demoVy > 512) gTitle.demoVy = 512;
if (gTitle.demoVy < -512) gTitle.demoVy = -512;
gTitle.demoX += gTitle.demoVx;
gTitle.demoY += gTitle.demoVy;
if (gTitle.demoX < 0) { gTitle.demoX = 0; gTitle.demoVx = 0; }
if (gTitle.demoX > maxX) { gTitle.demoX = maxX; gTitle.demoVx = 0; }
// Y can go negative (cab off-screen above) -- let it. Only
// clamp at the bottom.
if (gTitle.demoY > maxY) { gTitle.demoY = maxY; gTitle.demoVy = 0; }
gTitle.cabSx = (int16_t)(gTitle.demoX >> ST_SUBPIXEL_SHIFT);
gTitle.cabSy = (int16_t)(gTitle.demoY >> ST_SUBPIXEL_SHIFT);
// Only re-show the cab once it's back inside visible Y.
if (gTitle.cabSy >= (int16_t)ST_SPRITE_Y_OFFSET) {
gTitle.cabVisible = true;
}
// Flame: per $6D6A, on whenever any direction bit is set in the
// mask. Build the mask from dx/dy the same way the C64 reads
// CIA1 PortA: bit0=UP bit1=DOWN bit2=LEFT bit3=RIGHT.
{
uint8_t mask = 0u;
if (dy < 0) mask |= 0x01u; // UP
if (dy > 0) mask |= 0x02u; // DOWN
if (dx < 0) mask |= 0x04u; // LEFT
if (dx > 0) mask |= 0x08u; // RIGHT
gTitle.flameDirMask = mask;
gTitle.flameVisible = (mask != 0u);
if (gTitle.flameVisible) {
gTitle.flameParity ^= 1u;
}
}
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 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.
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);
// 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.
// Run the C64 title intro state machine (sparkle -> passenger
// walk -> taxi takeoff -> scripted demo) and draw whichever
// sprites are visible this frame.
titleTick();
// sprite-X -> visible col = sprite-X - 24 (C64 border offset).
// sprite-Y -> visible row = sprite-Y - 50 (top border).
// Draw lowest priority first: flame (sprite 2), then passenger
// (sprite 1), then cab (sprite 0) on top.
if (gTitle.flameVisible && (gTitle.flameParity & 1u) &&
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) {
// Map live C64 sprite ptr -> sprite-sheet cel index.
// $C4..$CB -> cels 0..7 (walk-LEFT cels then boarding)
// $D9 -> cel 8 (3rd sparkle cel from $66D6)
int16_t cel;
if (gTitle.passengerPtr == 0xD9) {
cel = ST_PASSENGER_CEL_SPARKLE;
} else {
cel = (int16_t)gTitle.passengerPtr - 0xC4;
}
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_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 used to be. Then we'll
// save+draw at the new positions below.
if (gRender.taxiHasBackup) {
jlSpriteRestoreUnder(stage, &gRender.taxiBackup);
gRender.taxiHasBackup = 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);
// Cel = idle (0) when not thrusting, otherwise cycle through the
// three authored flame frames (1, 2, 3). Falls back to cel 0 if
// a thrust cel isn't authored. During the crash anim ($6B24/$6B4C
// in the C64) the original walks sprite-0 through death cels
// $CC..$D1 at a slowing rate; the port doesn't have those cels
// so the cab keeps its normal sprite as it falls -- the falling
// motion + crash SFX + voice-1 scream are the cue.
if (game->taxi.thrusting) {
taxiCel = (uint8_t)(1u +
(game->taxi.thrustFrame / ST_THRUST_CEL_TICKS));
if (taxiCel >= ST_TAXI_CEL_COUNT) {
taxiCel = 0u;
}
} else {
taxiCel = 0u;
}
if (gRender.taxiCels[taxiCel] == NULL) {
taxiCel = 0u;
}
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,
&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;
// Flame placeholder (only when the cab sprite isn't authored,
// since cels 1..3 of the authored asset already include the
// flame baked in). C64 sprite 2 ($6D6A, writes $7177/$7199)
// is positioned at
// (taxi_col - 2, taxi_row), cel-indexed by $716A direction;
// we use sprite1Color ($D028) so per-level palette is honored.
if (game->taxi.thrusting) {
jlFillRect(stage,
(int16_t)(px + ST_FLAME_OFFSET_X_PX),
(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;
if (!p->active || p->onboard) {
continue; // onboard passengers ride invisibly inside the cab
}
cel = gRender.passengerCels[p->walkPhase % ST_PASSENGER_CEL_COUNT];
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_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(void) {
jlSpriteT *cels[ST_SPRITE_SHEET_CELS];
uint16_t count;
uint16_t i;
// .spr blob carries all 27 cels (9 cols x 3 rows of 3x3-tile
// chunky 4bpp blobs) in PNG reading order: row 0 taxi, row 1
// passenger, row 2 flame. The padding slots between named
// sprites are loaded but never referenced; they leak ~1.7 KB
// at startup which is well below the cost of writing a
// free-loop here (extra code in _ROOT eats the IIgs cluster
// budget more than the heap fragments hurt).
memset(cels, 0, sizeof(cels));
count = jlSpriteBankLoad(ST_SPRITE_SHEET_PATH, cels, ST_SPRITE_SHEET_CELS, NULL);
if (count == 0u) {
return false;
}
jlLogF("stRender: sprite sheet loaded (%u cels)", (unsigned)count);
for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_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];
}
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_PASSENGER_CEL_COUNT; i++) {
if (gRender.passengerCels[i] != NULL) {
jlSpriteDestroy(gRender.passengerCels[i]);
gRender.passengerCels[i] = NULL;
}
}
}
static bool loadFontSheet(jlSurfaceT *stage) {
jlTileT *fontTiles;
uint16_t count;
uint16_t i;
uint16_t palette[16];
(void)stage;
// Load font.tbk into a temp tile array, then paste each glyph
// into a regular surface that jlDrawText can sample. ~32 KB of
// temp memory for the 1000-tile font; freed before returning.
fontTiles = (jlTileT *)malloc(sizeof(jlTileT) * ST_FONT_TILES_MAX);
if (fontTiles == NULL) {
return false;
}
count = jlTileBankLoad(ST_FONT_PATH, fontTiles, ST_FONT_TILES_MAX,
NULL, palette);
if (count == 0u) {
free(fontTiles);
return false;
}
gRender.fontSurface = jlSurfaceCreate();
if (gRender.fontSurface == NULL) {
free(fontTiles);
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);
for (i = 0u; i < count; i++) {
uint8_t bx = (uint8_t)(i % ST_FONT_COLS);
uint8_t by = (uint8_t)(i / ST_FONT_COLS);
jlTilePaste(gRender.fontSurface, bx, by, &fontTiles[i]);
}
gRender.fontReady = true;
free(fontTiles);
return true;
}
// 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));
}
}
}