joeylib2/src/core/assetLoad.c
2026-09-29 17:23:29 -05:00

695 lines
28 KiB
C

// Native asset loaders for baked .spr and .tbk files produced by
// tools/assetbake/assetbake.py. Both formats are documented in detail
// in that tool; the short version:
//
// .spr cross-target chunky 4bpp sprite cels (the Phase 11 sprite
// walker reads chunky and c2p's inline at draw time, so a
// single bake serves every platform)
// .tbk per-target planar tile bytes (Amiga plane-major, ST row-
// major-with-planes-per-row, DOS+IIgs chunky 4bpp); the
// loader validates the file's target byte matches the build
// platform and refuses mismatches
//
// Both share a 44-byte header:
// 0 4 magic ("JSP1" or "JTB1")
// 4 1 target (0=cross-platform, 1=amiga, 2=ST, 3=dos, 4=iigs)
// 5 1 hasPalette
// 6 2 widthTiles + heightTiles (sprite) / tileCount LE16 (tile)
// 8 4 cellCount LE16 + reserved (sprite) / reserved (tile)
// 12 32 palette[16] LE16 $0RGB (zeros if hasPalette==0)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "joey/file.h"
#include "joey/sprite.h"
#include "joey/tile.h"
#include "port.h"
#include "spriteInternal.h"
#include "spritePrecompiled.h"
#if defined(JOEYLIB_PLATFORM_ATARIST)
// The Atari ST shifted-phase DRAW thunk tail-jumps to this shared walker via
// a baked absolute address; the .spc loader re-stamps it after load (the only
// port needing any relocation -- see spritePrecompiled.h).
extern void stSpriteDrawShiftWalker(void);
#endif
// IIgs segment placement (PERF-AUDIT #60): this TU carries NO named
// segment directive, deliberately. An earlier comment here claimed the
// file-IO was concentrated in a named segment to keep the stdio
// cluster from overflowing the root bank -- that directive never
// existed, and the failure mode it guarded against ("Code exceeds code
// bank size" / "Address is not in current bank") was a problem of the
// ORCA-Linker era; the clang/OMF pipeline splits oversized segments
// automatically. Do not add a segment directive here without link-map
// evidence -- root-segment packing is pressure-sensitive (see the
// bank-overflow notes in the feedback memory).
// ----- Constants -----
#define ASSET_HEADER_BYTES 44
#define ASSET_PALETTE_OFFSET 12
#define ASSET_PALETTE_ENTRIES 16
// All game data is opened relative to this directory (see joey/file.h).
#define DATA_DIR_PREFIX "DATA/"
#define DATA_PATH_MAX 256
// Tile-bank payload reads stream the whole run in big freads instead of
// one 32-byte fread per tile: jlTileT is exactly TILE_BYTES with no
// padding (compile-time-checked below), so tiles are contiguous in the
// output array. The old per-tile loop issued 1000 32-byte freads for a
// full font bank -- on the IIgs each fread is a GS/OS round trip through
// SmartPort firmware, which dominated (and on heavily-sparse .tbk files
// wedged) asset loading. NOTE: a single fread is NOT guaranteed to
// satisfy a large request in one call (the IIgs libc returned ~4 KB of
// an 8 KB request), so jlTileBankLoad loops the fread until the payload
// is fully read -- see the CONTRACT FIX comment there.
// Compile-time guard for the contiguous read: consecutive jlTileT
// entries must be contiguous pixel payloads (no padding), or the
// bulk fread would misalign every tile after the first.
typedef char AssetTileSizeCheckT[(sizeof(jlTileT) == TILE_BYTES) ? 1 : -1];
// Tile geometry comes from joey/tile.h, the single source of truth for
// the 4bpp packed layout (PERF-AUDIT #30): the per-tile payload read
// below is TILE_BYTES -- exactly what jlTileT.pixels holds -- and the
// largest legitimately drawable sprite cel is the full surface grid,
// TILE_BLOCKS_PER_ROW x TILE_BLOCKS_PER_COL (SURFACE_WIDTH/HEIGHT over
// TILE_PIXELS_PER_SIDE, 40x25 on the 320x200 surface). Any cel larger
// than that can never be fully on-screen, so reject crafted/truncated
// headers claiming more, which would otherwise force an unbounded
// per-cel allocation (DoS) and feed the downstream emitters/draw
// walkers out-of-range dimensions.
#define ASSET_MAX_TILES_W TILE_BLOCKS_PER_ROW
#define ASSET_MAX_TILES_H TILE_BLOCKS_PER_COL
// Per-platform expected target byte for .tbk files. .spr files carry
// target=0 (cross-platform chunky) and pass on every build.
#if defined(JOEYLIB_PLATFORM_AMIGA)
#define EXPECTED_TILE_TARGET 1u
#elif defined(JOEYLIB_PLATFORM_ATARIST)
#define EXPECTED_TILE_TARGET 2u
#elif defined(JOEYLIB_PLATFORM_DOS)
#define EXPECTED_TILE_TARGET 3u
#elif defined(JOEYLIB_PLATFORM_IIGS)
#define EXPECTED_TILE_TARGET 4u
#elif defined(JOEYLIB_PLATFORM_BLANK)
#define EXPECTED_TILE_TARGET 3u // chunky 4bpp, same as DOS (uses the generic tile ops)
#elif defined(JOEYLIB_PLATFORM_X68000)
// Plane-major like the Amiga (src/m68k/planarTile.h reads jlTileT.pixels as
// 4 planes x 8 rows), baked by assetbake --target x68000.
#define EXPECTED_TILE_TARGET 5u
#else
#error "Unknown platform for asset loader"
#endif
// Per-platform target byte for .spc files (pre-compiled sprite code, so
// it is per MACHINE CODE, not per pixel format: every port's banks carry
// their own byte, so a bank baked for any other build is refused). Mirrors
// SPC_TARGET in tools/spritebake.
#if defined(JOEYLIB_PLATFORM_AMIGA)
#define EXPECTED_SPC_TARGET 1u
#elif defined(JOEYLIB_PLATFORM_ATARIST)
#define EXPECTED_SPC_TARGET 2u
#elif defined(JOEYLIB_PLATFORM_DOS) || defined(JOEYLIB_PLATFORM_BLANK)
#define EXPECTED_SPC_TARGET 3u
#elif defined(JOEYLIB_PLATFORM_IIGS)
#define EXPECTED_SPC_TARGET 4u
#elif defined(JOEYLIB_PLATFORM_X68000)
#define EXPECTED_SPC_TARGET 5u
#endif
// ----- Prototypes -----
static bool openReadValidateHeader(const char *path, const char *magic, uint8_t expectedTarget, uint8_t *header, uint16_t *outPalette, FILE **outFp);
static void readPaletteFromHeader(const uint8_t *header, uint16_t *outPalette);
// ----- Internal helpers (alphabetical) -----
// Open `path`, read the shared 44-byte header, and validate the magic
// + target byte common to both .spr and .tbk. On success leaves *outFp
// open (caller must fclose) and returns true; on any failure closes
// the file (if opened) and returns false. When outPalette is non-NULL
// and the header advertises a palette (header[5] != 0), the palette is
// decoded into outPalette. Shared by jlSpriteBankLoad and
// jlTileBankLoad so the open/read/validate/parse-palette boilerplate
// (and its fclose-on-error pattern) lives in one place.
static bool openReadValidateHeader(const char *path, const char *magic, uint8_t expectedTarget, uint8_t *header, uint16_t *outPalette, FILE **outFp) {
FILE *fp;
fp = jlDataOpen(path, "rb");
if (fp == NULL) {
return false;
}
if (fread(header, 1, ASSET_HEADER_BYTES, fp) != ASSET_HEADER_BYTES) {
fclose(fp);
return false;
}
if (memcmp(header, magic, 4) != 0) {
fclose(fp);
return false;
}
if (header[4] != expectedTarget) {
fclose(fp);
return false;
}
if (outPalette != NULL && header[5] != 0u) {
readPaletteFromHeader(header, outPalette);
}
*outFp = fp;
return true;
}
static void readPaletteFromHeader(const uint8_t *header, uint16_t *outPalette) {
uint8_t i;
for (i = 0; i < ASSET_PALETTE_ENTRIES; i++) {
outPalette[i] = (uint16_t)(header[ASSET_PALETTE_OFFSET + i * 2]
| (header[ASSET_PALETTE_OFFSET + i * 2 + 1] << 8));
}
}
// ----- Public API (alphabetical) -----
// Open a game-data file by its name relative to DATA/. Forcing the prefix
// here is the single place the DATA/ convention is enforced -- every asset
// loader and example routes file opens through this, so callers never
// hard-code "DATA/" and the runtime tree layout stays consistent across
// ports. Returns NULL if the joined path overflows DATA_PATH_MAX or the
// file cannot be opened.
FILE *jlDataOpen(const char *name, const char *mode) {
char path[DATA_PATH_MAX];
size_t prefixLen = sizeof(DATA_DIR_PREFIX) - 1u; // literal length, no NUL
size_t nameLen;
// A manual prefix concat instead of snprintf: the format is fixed
// ("DATA/" + name), and snprintf drags the whole C formatted-output
// engine (~20 KB of code plus a soft-float dependency) into every
// binary that opens a data file, which the IIgs bank-0 budget cannot
// spare. strlen/memcpy do the same join with none of that.
if (name == NULL) {
return NULL;
}
nameLen = strlen(name);
if (prefixLen + nameLen + 1u > sizeof(path)) {
return NULL;
}
memcpy(path, DATA_DIR_PREFIX, prefixLen);
memcpy(path + prefixLen, name, nameLen + 1u); // copy the NUL too
return fopen(path, mode);
}
// Returns the number of cels successfully loaded into outCels[0..N).
// A return value strictly less than min(headerCellCount, maxCels)
// indicates the file was truncated/corrupt mid-stream relative to its
// own header count; the cels that were read are still valid.
uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t maxCels, uint16_t *outPalette) {
FILE *fp;
uint8_t header[ASSET_HEADER_BYTES];
uint8_t widthTiles;
uint8_t heightTiles;
uint16_t cellCount;
uint32_t tileBytes;
uint16_t i;
uint16_t loaded;
uint16_t toLoad;
if (path == NULL || outCels == NULL || maxCels == 0u) {
return 0u;
}
// Magic "JSP1" + target byte = 0 (cross-platform chunky). Anything
// else means the file was either baked wrong or is a stale .spr
// from the old compiled-codegen pipeline.
if (!openReadValidateHeader(path, "JSP1", 0u, header, outPalette, &fp)) {
return 0u;
}
widthTiles = header[6];
heightTiles = header[7];
cellCount = (uint16_t)(header[8] | (header[9] << 8));
// Reject degenerate (0 in either dimension -> tileBytes 0, an
// implementation-defined malloc(0)) and oversized (a crafted header
// could force a multi-MB per-cel malloc) sprites. Matches
// jlSpriteCreate's invariant and the 40x25-tile surface ceiling.
if (widthTiles == 0u || heightTiles == 0u ||
widthTiles > ASSET_MAX_TILES_W || heightTiles > ASSET_MAX_TILES_H) {
fclose(fp);
return 0u;
}
tileBytes = (uint32_t)widthTiles * (uint32_t)heightTiles * (uint32_t)TILE_BYTES;
toLoad = (cellCount < maxCels) ? cellCount : maxCels;
loaded = 0u;
for (i = 0; i < toLoad; i++) {
uint8_t *buf;
jlSpriteT *sp;
// jlpBigAlloc for the same #31/#81 reasons as sprite.c: these
// buffers overflow the tiny IIgs C heap, whose malloc returns
// garbage instead of NULL at exhaustion.
buf = (uint8_t *)jlpBigAlloc(tileBytes);
if (buf == NULL) {
break;
}
if (fread(buf, 1, (size_t)tileBytes, fp) != (size_t)tileBytes) {
jlpBigFree(buf);
break;
}
sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT));
if (sp == NULL) {
jlpBigFree(buf);
break;
}
// Shared init path (spriteInternal.h, claudeDocs/PERF-AUDIT.md #32): sets
// the 0xFF/0 compiled-state sentinels and registers the sprite
// for shutdown. ownsTileData true: the cel owns its buf copy.
spriteInitFields(sp, buf, widthTiles, heightTiles, true);
outCels[i] = sp;
loaded++;
}
fclose(fp);
return loaded;
}
// Load a .spc pre-compiled sprite bank (tools/spritebake output) for THIS
// platform: same as jlSpriteBankLoad, but each cel arrives already compiled
// -- the pre-emitted native blit-routine image is copied straight into a
// codegen-arena slot and sp->slot + sp->routineOffsets are stamped from the
// file, so no runtime jlSpriteCompile is needed. A cel that cannot fit the
// arena (or whose bank had nothing to compile) is left uncompiled exactly as
// jlSpriteBankLoad leaves every cel, so the interpreter fallback still draws
// it -- callers can treat this as a drop-in faster jlSpriteBankLoad.
uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels, uint16_t maxCels, uint16_t *outPalette) {
FILE *fp;
uint8_t header[ASSET_HEADER_BYTES];
uint8_t *imageBuf;
uint8_t widthTiles;
uint8_t heightTiles;
uint8_t shiftCount;
uint16_t cellCount;
uint32_t tileBytes;
uint16_t toLoad;
uint16_t i;
uint16_t loaded;
if (path == NULL || outCels == NULL || maxCels == 0u) {
return 0u;
}
// "JSC1" + the per-machine target byte (pre-compiled code is machine-
// specific, validated like a .tbk but against the .spc target table).
if (!openReadValidateHeader(path, SPC_MAGIC, EXPECTED_SPC_TARGET, header, outPalette, &fp)) {
return 0u;
}
widthTiles = header[SPC_OFF_WIDTH_TILES];
heightTiles = header[SPC_OFF_HEIGHT_TILES];
cellCount = (uint16_t)(header[SPC_OFF_CELL_COUNT] | (header[SPC_OFF_CELL_COUNT + 1] << 8));
shiftCount = header[SPC_OFF_SHIFT_COUNT];
// Reject wrong geometry AND a shift count that does not match this build's
// table width -- a .spc baked for the wrong target would otherwise stamp a
// mis-sized routineOffsets table and the dispatcher would read garbage.
if (widthTiles == 0u || heightTiles == 0u ||
widthTiles > ASSET_MAX_TILES_W || heightTiles > ASSET_MAX_TILES_H ||
shiftCount != (uint8_t)JOEY_SPRITE_SHIFT_COUNT) {
fclose(fp);
return 0u;
}
tileBytes = (uint32_t)widthTiles * (uint32_t)heightTiles * (uint32_t)TILE_BYTES;
toLoad = (cellCount < maxCels) ? cellCount : maxCels;
// One reused staging buffer for each cel's routine image (bounded to
// <64 KB by the uint16 offset budget, same as the JIT). Read the image
// here BEFORE building the sprite so every fallible fread happens before
// spriteInitFields registers the sprite -- no dangling registry entry.
imageBuf = (uint8_t *)jlpBigAlloc(SPC_MAX_IMAGE_BYTES);
if (imageBuf == NULL) {
fclose(fp);
return 0u;
}
loaded = 0u;
for (i = 0; i < toLoad; i++) {
uint8_t meta[4u + (uint32_t)JOEY_SPRITE_SHIFT_COUNT * SPRITE_OP_COUNT * 2u + 2u];
uint16_t routeOff[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT];
uint16_t relocOffsets[JOEY_SPRITE_SHIFT_COUNT];
uint8_t *buf;
jlSpriteT *sp;
ArenaSlotT *slot;
uint32_t metaBytes;
uint32_t imageSize;
uint32_t p;
uint16_t relocCount;
uint8_t shift;
uint8_t op;
// celMeta: imageSize LE32, routineOffsets[shiftCount][3] LE16, relocCount LE16.
metaBytes = 4u + (uint32_t)shiftCount * SPRITE_OP_COUNT * 2u + 2u;
if (fread(meta, 1, (size_t)metaBytes, fp) != (size_t)metaBytes) {
break;
}
imageSize = (uint32_t)meta[0] | ((uint32_t)meta[1] << 8)
| ((uint32_t)meta[2] << 16) | ((uint32_t)meta[3] << 24);
p = 4u;
for (shift = 0; shift < shiftCount; shift++) {
for (op = 0; op < SPRITE_OP_COUNT; op++) {
routeOff[shift][op] = (uint16_t)(meta[p] | (meta[p + 1] << 8));
p += 2u;
}
}
relocCount = (uint16_t)(meta[p] | (meta[p + 1] << 8));
if (imageSize > SPC_MAX_IMAGE_BYTES || relocCount > shiftCount) {
break; // corrupt / out-of-spec
}
// Tile blob (owned by the cel; feeds dims + the interpreter/clip
// fallback + SAVE/RESTORE), then the routine image, then reloc sites.
buf = (uint8_t *)jlpBigAlloc(tileBytes);
if (buf == NULL) {
break;
}
if (fread(buf, 1, (size_t)tileBytes, fp) != (size_t)tileBytes) {
jlpBigFree(buf);
break;
}
if (imageSize > 0u && fread(imageBuf, 1, (size_t)imageSize, fp) != (size_t)imageSize) {
jlpBigFree(buf);
break;
}
if (relocCount > 0u) {
uint8_t relocRaw[JOEY_SPRITE_SHIFT_COUNT * 2u];
uint16_t r;
if (fread(relocRaw, 1, (size_t)relocCount * 2u, fp) != (size_t)relocCount * 2u) {
jlpBigFree(buf);
break;
}
for (r = 0; r < relocCount; r++) {
relocOffsets[r] = (uint16_t)(relocRaw[r * 2u] | (relocRaw[r * 2u + 1] << 8));
}
}
sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT));
if (sp == NULL) {
jlpBigFree(buf);
break;
}
spriteInitFields(sp, buf, widthTiles, heightTiles, true);
// Install the pre-compiled image. On arena-full (compact-and-retry,
// like jlSpriteCompile) leave the cel uncompiled -> interpreter, no
// error: the sprite still draws, just at the old speed.
slot = NULL;
if (imageSize > 0u) {
slot = codegenArenaAlloc(imageSize);
if (slot == NULL) {
codegenArenaCompact();
slot = codegenArenaAlloc(imageSize);
}
}
if (slot != NULL) {
uint8_t *slotBase = codegenArenaSlotBase(slot);
memcpy(slotBase, imageBuf, (size_t)imageSize);
for (shift = 0; shift < shiftCount; shift++) {
for (op = 0; op < SPRITE_OP_COUNT; op++) {
sp->routineOffsets[shift][op] = routeOff[shift][op];
}
}
#if defined(JOEYLIB_PLATFORM_ATARIST)
// Re-stamp the ST shifted-DRAW thunks' absolute jmp to the shared
// walker (the one relocation any port needs). 68k big-endian, so a
// native uint32 store lays the 4 address bytes high-word-first,
// matching the baked thunk layout.
{
uint16_t r;
for (r = 0; r < relocCount; r++) {
*(uint32_t *)(slotBase + relocOffsets[r]) = (uint32_t)(uintptr_t)stSpriteDrawShiftWalker;
}
}
#else
(void)relocOffsets;
#endif
sp->slot = slot;
}
outCels[i] = sp;
loaded++;
}
jlpBigFree(imageBuf);
fclose(fp);
return loaded;
}
// Serialize an already-compiled sprite bank to a .spc cache file -- the
// runtime twin of tools/spritebake. Call it after jlSpriteBankLoad +
// jlSpriteCompile so a later launch can jlSpriteBankLoadPrecompiled the cache
// and skip the JIT. This is the "ship the small cross-platform .spr, compile
// once, cache the (large) per-platform compiled bank" model -- ideal on the
// planar ports whose full .spc is far too big to ship on a floppy. The
// emitted routines are position-independent and routineOffsets are slot-
// relative, so the arena bytes serialize verbatim (Atari ST alone records the
// shifted-DRAW walker patch sites, which the loader re-stamps on reload).
// Cels without an arena slot (compile skipped / arena full) are written
// uncompiled and simply JIT again next run. `palette` (or NULL) is embedded
// so the cache is self-contained. Returns false if the destination is not
// writable (e.g. a write-protected disk) -- harmless; the app just keeps
// JITing every launch.
bool jlSpriteBankSavePrecompiled(const char *path, jlSpriteT **cels, uint16_t count, const uint16_t *palette) {
FILE *fp;
uint8_t widthTiles;
uint8_t heightTiles;
uint32_t tileBytes;
uint16_t i;
if (path == NULL || cels == NULL || count == 0u || cels[0] == NULL) {
return false;
}
widthTiles = cels[0]->widthTiles;
heightTiles = cels[0]->heightTiles;
tileBytes = (uint32_t)widthTiles * (uint32_t)heightTiles * (uint32_t)TILE_BYTES;
fp = jlDataOpen(path, "wb");
if (fp == NULL) {
return false;
}
// Shared 44-byte header (mirrors tools/spritebake + jlSpriteBankLoadPrecompiled).
fwrite(SPC_MAGIC, 1, 4, fp);
fputc((int)EXPECTED_SPC_TARGET, fp);
fputc(palette != NULL ? 1 : 0, fp);
fputc((int)widthTiles, fp);
fputc((int)heightTiles, fp);
fputc((int)(count & 0xFFu), fp);
fputc((int)((count >> 8) & 0xFFu), fp);
fputc((int)JOEY_SPRITE_SHIFT_COUNT, fp);
fputc(0, fp); // reserved
for (i = 0; i < ASSET_PALETTE_ENTRIES; i++) {
uint16_t v = (palette != NULL) ? palette[i] : 0u;
fputc((int)(v & 0xFFu), fp);
fputc((int)((v >> 8) & 0xFFu), fp);
}
for (i = 0; i < count; i++) {
jlSpriteT *sp = cels[i];
uint16_t relocOffsets[JOEY_SPRITE_SHIFT_COUNT];
uint16_t relocCount = 0u;
uint32_t imageSize = (sp != NULL && sp->slot != NULL) ? sp->slot->size : 0u;
uint8_t shift;
uint8_t op;
uint16_t r;
#if defined(JOEYLIB_PLATFORM_ATARIST)
if (sp != NULL && sp->slot != NULL) {
for (shift = 0; shift < (uint8_t)JOEY_SPRITE_SHIFT_COUNT; shift++) {
uint16_t d = sp->routineOffsets[shift][SPRITE_OP_DRAW];
if ((shift & 7u) != 0u && d != SPRITE_NOT_COMPILED) {
relocOffsets[relocCount] = (uint16_t)(d + SPC_ST_WALKER_JMP_OFFSET);
relocCount++;
}
}
}
#endif
// celMeta: imageSize LE32, routineOffsets[shift][op] LE16, relocCount LE16.
fputc((int)(imageSize & 0xFFu), fp);
fputc((int)((imageSize >> 8) & 0xFFu), fp);
fputc((int)((imageSize >> 16) & 0xFFu), fp);
fputc((int)((imageSize >> 24) & 0xFFu), fp);
for (shift = 0; shift < (uint8_t)JOEY_SPRITE_SHIFT_COUNT; shift++) {
for (op = 0; op < SPRITE_OP_COUNT; op++) {
uint16_t v = (sp != NULL) ? sp->routineOffsets[shift][op] : (uint16_t)SPRITE_NOT_COMPILED;
fputc((int)(v & 0xFFu), fp);
fputc((int)((v >> 8) & 0xFFu), fp);
}
}
fputc((int)(relocCount & 0xFFu), fp);
fputc((int)((relocCount >> 8) & 0xFFu), fp);
// tileData (raw cel, for the interpreter/clip fallback + dims).
if (sp != NULL && sp->tileData != NULL) {
fwrite(sp->tileData, 1, (size_t)tileBytes, fp);
} else {
uint32_t z;
for (z = 0; z < tileBytes; z++) {
fputc(0, fp);
}
}
// image: the compiled routines, straight from the arena slot.
if (imageSize > 0u) {
fwrite(codegenArenaSlotBase(sp->slot), 1, (size_t)imageSize, fp);
}
for (r = 0; r < relocCount; r++) {
fputc((int)(relocOffsets[r] & 0xFFu), fp);
fputc((int)((relocOffsets[r] >> 8) & 0xFFu), fp);
}
}
if (fclose(fp) != 0) {
return false;
}
return true;
}
// Returns the number of tiles successfully loaded. outValid[0..maxTiles)
// is always fully written: true for each tile read, false for the rest
// (including slots past a truncated/short file), so the caller need not
// pre-zero it. A return value strictly less than min(headerTileCount,
// maxTiles) indicates the file was truncated/corrupt mid-stream.
uint16_t jlTileBankLoad(const char *path, jlTileT *outTiles, uint16_t maxTiles,
bool *outValid, uint16_t *outPalette) {
FILE *fp;
uint8_t header[ASSET_HEADER_BYTES];
uint16_t tileCount;
uint16_t i;
uint16_t loaded;
uint16_t toLoad;
size_t got;
if (outTiles == NULL || maxTiles == 0u) {
return 0u;
}
// Define the whole outValid array up front -- BEFORE the file is
// opened -- so leftover slots read false exactly as the tile.h
// contract promises, regardless of caller pre-zeroing. Clearing it
// only after the header validated left a refused, missing or
// short-header load reporting whatever an earlier load into the
// same array had marked valid (caught by scripts/check-tilebank.sh).
if (outValid != NULL) {
for (i = 0; i < maxTiles; i++) {
outValid[i] = false;
}
}
// The target byte for .tbk must match the build platform. .spr
// files carry target=0; tiles carry the per-platform value. A
// mismatch is a build-system bug -- the per-target make rule
// should have produced a .tbk with the matching target byte. A
// NULL path needs no guard here: jlDataOpen refuses it.
if (!openReadValidateHeader(path, "JTB1", EXPECTED_TILE_TARGET, header, outPalette, &fp)) {
return 0u;
}
tileCount = (uint16_t)(header[6] | (header[7] << 8));
toLoad = (tileCount < maxTiles) ? tileCount : maxTiles;
// Stream the whole tile payload as ONE contiguous byte run: jlTileT
// is pure pixels[TILE_BYTES], so tiles are back-to-back in outTiles[].
// CONTRACT FIX (2026-07-12): the old batched loop treated a short
// fread as EOF (`if gotTiles < batch: break`). That is WRONG on the
// IIgs, whose libc fread does not always satisfy a large request in
// ONE call -- it returned only ~133 of Space Taxi's 256 tiles,
// truncating every tile past $84 so the title logo flip-book (chars
// $85..$88) never loaded and never animated (acceptance #3). Loop
// until the requested bytes are read or fread returns 0 (true
// EOF/error). Byte-granular, not tile-granular, so a partial final
// tile from a short read cannot desync the stream. On ports whose
// fread already fills the request (DOS/Amiga/ST) this is behaviour-
// identical: same total bytes, same `loaded` count.
{
uint8_t *payload = (uint8_t *)outTiles[0].pixels;
uint32_t wantBytes = (uint32_t)toLoad * (uint32_t)TILE_BYTES;
uint32_t doneBytes = 0u;
while (doneBytes < wantBytes) {
got = fread(payload + doneBytes, 1, (size_t)(wantBytes - doneBytes), fp);
if (got == 0u) {
break;
}
doneBytes += (uint32_t)got;
}
loaded = (uint16_t)(doneBytes / (uint32_t)TILE_BYTES);
if (outValid != NULL) {
for (i = 0; i < loaded; i++) {
outValid[i] = true;
}
}
}
fclose(fp);
return loaded;
}
// Streaming variant of jlTileBankLoad: pastes tiles onto dst's block
// grid as they arrive instead of filling a caller buffer, so the
// caller needs NO tile array at all (contract in joey/tile.h). The
// scratch is a small static -- 8 tiles per fread keeps the read count
// low without stressing the 65816 soft stack.
uint16_t jlTileBankLoadToSurface(const char *path, jlSurfaceT *dst, uint16_t *outPalette) {
static jlTileT scratch[8];
FILE *fp;
uint8_t header[ASSET_HEADER_BYTES];
uint16_t tileCount;
uint16_t toLoad;
uint16_t loaded;
uint16_t batch;
uint16_t gotTiles;
uint16_t i;
uint8_t bx;
uint8_t by;
size_t got;
if (path == NULL || dst == NULL) {
return 0u;
}
if (!openReadValidateHeader(path, "JTB1", EXPECTED_TILE_TARGET, header, outPalette, &fp)) {
return 0u;
}
tileCount = (uint16_t)(header[6] | (header[7] << 8));
toLoad = (uint16_t)(TILE_BLOCKS_PER_ROW * TILE_BLOCKS_PER_COL);
if (tileCount < toLoad) {
toLoad = tileCount;
}
loaded = 0u;
while (loaded < toLoad) {
batch = (uint16_t)(toLoad - loaded);
if (batch > (uint16_t)(sizeof(scratch) / sizeof(scratch[0]))) {
batch = (uint16_t)(sizeof(scratch) / sizeof(scratch[0]));
}
got = fread(scratch[0].pixels, 1, (size_t)batch * TILE_BYTES, fp);
gotTiles = (uint16_t)(got / TILE_BYTES);
for (i = 0; i < gotTiles; i++) {
bx = (uint8_t)((loaded + i) % TILE_BLOCKS_PER_ROW);
by = (uint8_t)((loaded + i) / TILE_BLOCKS_PER_ROW);
jlTilePaste(dst, bx, by, &scratch[i]);
}
loaded = (uint16_t)(loaded + gotTiles);
if (gotTiles < batch) {
break;
}
}
fclose(fp);
return loaded;
}