// 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)
// Chunky 4bpp while the port is on the generic backend, so it shares DOS's
// target byte -- the payload is bytes, so the endian difference does not
// matter. This gets its own target the moment the storage model is decided
// (a text-plane port would bake Amiga-shaped tiles and want target 1).
#define EXPECTED_TILE_TARGET   3u
#else
#error "Unknown platform for asset loader"
#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];
    int     written;

    // snprintf's %s reads `name` through varargs, which keeps the whole
    // path build in one place and out of any manual pointer arithmetic.
    written = snprintf(path, sizeof(path), DATA_DIR_PREFIX "%s", name);
    if (written < 0 || (size_t)written >= sizeof(path)) {
        return NULL;
    }
    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, 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 via the shared EXPECTED target).
    if (!openReadValidateHeader(path, SPC_MAGIC, EXPECTED_TILE_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 = codegenArenaBase() + slot->offset;

            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_TILE_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(codegenArenaBase() + sp->slot->offset, 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 (path == NULL || outTiles == NULL || maxTiles == 0u) {
        return 0u;
    }
    // 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.
    if (!openReadValidateHeader(path, "JTB1", EXPECTED_TILE_TARGET, header, outPalette, &fp)) {
        return 0u;
    }
    tileCount = (uint16_t)(header[6] | (header[7] << 8));

    // Define the whole outValid array up front so leftover slots read
    // false exactly as the tile.h contract promises, regardless of
    // caller pre-zeroing.
    if (outValid != NULL) {
        for (i = 0; i < maxTiles; i++) {
            outValid[i] = false;
        }
    }

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