// 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 #include #include #include #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; }