// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // // tileBankHost.c - Host-cc gate for the baked tile-bank loaders, // jlTileBankLoad and jlTileBankLoadToSurface (src/core/assetLoad.c). // // Built by scripts/check-tilebank.sh as a whole BLANK-port program (core + // generics + src/blank/blank.c + this file), so the REAL loader feeds the // REAL generic tile paste and every pixel is read back through the public // jlSamplePixel. BLANK draws chunky 4bpp like DOS and assetLoad.c expects the // DOS target byte there, so the script stages tests/host/tileBank.png with // tests/host/tileBankStage.sh for target dos: the DOS bake is the bank that // must load, and every other assetbake target's bake must be refused. // // Runs in the script's temp directory, whose DATA/ holds (the staging // script's names and layout, shared with examples/tilebank): // own.tbk the DOS bake of tileBank.png // refuse.tbk one bake per other target; their names arrive as argv[1..] // expect.bin the PNG decoded by PIL, independent of assetbake's // encoders: tileCount LE16, palette[16] LE16 $0RGB, then // tileCount * 64 color indices (tiles in bank order, each // row-major) // The malformed banks (bad magic, short header, truncated payload, no // palette, more tiles than a surface holds) are cut from own.tbk here, so // their parameters live beside the checks that depend on them. // // Prints one line per check and a final tally; exits nonzero on any failure. #include #include #include #include "joey/joey.h" #include "joey/file.h" // ----- Constants ----- #define BAD_MAGIC_BANK "badmagic.tbk" #define BIG_BANK "big.tbk" #define EXPECTED_FILE "expect.bin" #define GOOD_BANK "own.tbk" #define MISSING_BANK "missing.tbk" #define NO_PALETTE_BANK "nopal.tbk" #define SHORT_HEADER_BANK "shorthdr.tbk" #define TRUNCATED_BANK "trunc.tbk" // .tbk header fields (the format is documented in tools/assetbake/assetbake.c). #define TBK_HEADER_BYTES 44 #define TBK_OFF_MAGIC 0 #define TBK_OFF_HAS_PALETTE 5 #define TBK_OFF_TILE_COUNT 6 #define LE16_BYTES 2 #define PALETTE_COLORS JL_SURFACE_COLORS_PER_PALETTE #define TILE_PIXELS (JL_TILE_PIXELS_PER_SIDE * JL_TILE_PIXELS_PER_SIDE) #define SURFACE_TILE_BLOCKS (JL_TILE_BLOCKS_PER_ROW * JL_TILE_BLOCKS_PER_COL) // expect.bin layout (see the header comment). #define EXPECTED_OFF_PALETTE LE16_BYTES #define EXPECTED_OFF_PIXELS (EXPECTED_OFF_PALETTE + PALETTE_COLORS * LE16_BYTES) #define EXPECTED_MAX_TILES SURFACE_TILE_BLOCKS // One tile more than a surface holds, so jlTileBankLoadToSurface's 40x25 // cap is exercised. Also the capacity of the jlTileT buffer every load uses. #define BIG_BANK_TILES (SURFACE_TILE_BLOCKS + 1) #define TILE_CAPACITY BIG_BANK_TILES #define BAD_MAGIC_BYTE 'X' #define FILL_COLOR 9u // pre-paste color; blocks past a bank must keep it (examples/tilebank clears to the same, so its hash can match ours) #define PALETTE_SENTINEL 0xBEEFu #define SHORT_HEADER_BYTES (TBK_HEADER_BYTES / 2) #define SMALL_MAX_TILES 5u #define TILE_SENTINEL 0xA5u #define TRUNCATED_EXTRA_BYTES (JL_TILE_BYTES / 2) // half a tile after the last whole one #define TRUNCATED_TILES 10u // ----- Prototypes ----- static void check(const char *bank, const char *what, bool ok); static bool craftBanks(void); static bool loadExpected(void); static bool paletteIsExpected(void); static bool paletteUntouched(void); static void pasteLoadedTiles(uint16_t count); static uint16_t readLe16(const uint8_t *bytes); static void resetBuffers(void); static bool surfaceMatches(uint16_t tilesPasted); static void testArgumentGuards(void); static void testBigBank(void); static void testGoodBank(void); static void testMaxTiles(void); static void testNoPalette(void); static void testRefused(const char *bank); static void testToSurface(void); static void testTruncated(void); static bool tilesUntouched(uint16_t first, uint16_t end); static bool validRangeIs(uint16_t first, uint16_t end, bool want); static bool writeBank(const char *name, uint32_t length); static void writeLe16(uint8_t *bytes, uint16_t value); // ----- State ----- static uint16_t gExpectedCount; static uint16_t gExpectedPalette[PALETTE_COLORS]; static uint8_t gExpectedPixels[EXPECTED_MAX_TILES * TILE_PIXELS]; static uint16_t gFail = 0u; static uint8_t gFile[TBK_HEADER_BYTES + BIG_BANK_TILES * JL_TILE_BYTES]; static uint16_t gPalette[PALETTE_COLORS]; static uint16_t gPass = 0u; static jlSurfaceT *gSurface = NULL; static jlTileT gTiles[TILE_CAPACITY]; static bool gValid[TILE_CAPACITY]; static void check(const char *bank, const char *what, bool ok) { if (ok) { gPass++; printf(" ok %s: %s\n", bank, what); } else { gFail++; printf(" FAIL %s: %s\n", bank, what); } } // Cut the malformed banks from own.tbk (see the header comment). The big // bank goes last because it rewrites gFile's count and extends its payload. static bool craftBanks(void) { FILE *fp; uint32_t goodLength; uint16_t i; uint8_t saved; bool ok; fp = jlDataOpen(GOOD_BANK, "rb"); if (fp == NULL) { return false; } goodLength = (uint32_t)fread(gFile, 1, sizeof(gFile), fp); fclose(fp); if (goodLength != TBK_HEADER_BYTES + (uint32_t)gExpectedCount * JL_TILE_BYTES) { return false; } if (!writeBank(SHORT_HEADER_BANK, SHORT_HEADER_BYTES)) { return false; } if (!writeBank(TRUNCATED_BANK, TBK_HEADER_BYTES + TRUNCATED_TILES * JL_TILE_BYTES + TRUNCATED_EXTRA_BYTES)) { return false; } saved = gFile[TBK_OFF_MAGIC]; gFile[TBK_OFF_MAGIC] = BAD_MAGIC_BYTE; ok = writeBank(BAD_MAGIC_BANK, goodLength); gFile[TBK_OFF_MAGIC] = saved; if (!ok) { return false; } saved = gFile[TBK_OFF_HAS_PALETTE]; gFile[TBK_OFF_HAS_PALETTE] = 0u; ok = writeBank(NO_PALETTE_BANK, goodLength); gFile[TBK_OFF_HAS_PALETTE] = saved; if (!ok) { return false; } // Tile i of the big bank repeats tile (i % tileCount) of the good one. for (i = gExpectedCount; i < BIG_BANK_TILES; i++) { memcpy(&gFile[TBK_HEADER_BYTES + (uint32_t)i * JL_TILE_BYTES], &gFile[TBK_HEADER_BYTES + (uint32_t)(i % gExpectedCount) * JL_TILE_BYTES], JL_TILE_BYTES); } writeLe16(&gFile[TBK_OFF_TILE_COUNT], BIG_BANK_TILES); return writeBank(BIG_BANK, TBK_HEADER_BYTES + (uint32_t)BIG_BANK_TILES * JL_TILE_BYTES); } // Read expect.bin. The fixture must span more than one block row (so the // 40-block wrap is exercised) and fewer blocks than a surface (so blocks past // the bank can be seen keeping their prior content). static bool loadExpected(void) { FILE *fp; uint8_t head[EXPECTED_OFF_PIXELS]; uint32_t pixelBytes; uint16_t i; bool ok; fp = jlDataOpen(EXPECTED_FILE, "rb"); if (fp == NULL) { return false; } ok = fread(head, 1, sizeof(head), fp) == sizeof(head); if (ok) { gExpectedCount = readLe16(head); ok = gExpectedCount > JL_TILE_BLOCKS_PER_ROW && gExpectedCount < SURFACE_TILE_BLOCKS; } if (ok) { for (i = 0; i < PALETTE_COLORS; i++) { gExpectedPalette[i] = readLe16(&head[EXPECTED_OFF_PALETTE + i * LE16_BYTES]); } pixelBytes = (uint32_t)gExpectedCount * TILE_PIXELS; ok = fread(gExpectedPixels, 1, pixelBytes, fp) == pixelBytes && fgetc(fp) == EOF; } fclose(fp); return ok; } static bool paletteIsExpected(void) { return memcmp(gPalette, gExpectedPalette, sizeof(gPalette)) == 0; } static bool paletteUntouched(void) { uint16_t i; for (i = 0; i < PALETTE_COLORS; i++) { if (gPalette[i] != PALETTE_SENTINEL) { return false; } } return true; } // Clear the surface to FILL_COLOR, then paste gTiles[0..count) in the same // row-major block order jlTileBankLoadToSurface uses. static void pasteLoadedTiles(uint16_t count) { uint16_t i; jlSurfaceClear(gSurface, FILL_COLOR); for (i = 0; i < count; i++) { jlTilePaste(gSurface, (uint8_t)(i % JL_TILE_BLOCKS_PER_ROW), (uint8_t)(i / JL_TILE_BLOCKS_PER_ROW), &gTiles[i]); } } static uint16_t readLe16(const uint8_t *bytes) { return (uint16_t)(bytes[0] | (bytes[1] << CHAR_BIT)); } // Poison every output so a check can tell written slots from untouched ones: // tiles and palette get sentinels, and outValid is preset TRUE so a loader // that fails to clear it is caught. static void resetBuffers(void) { uint16_t i; memset(gTiles, TILE_SENTINEL, sizeof(gTiles)); for (i = 0; i < TILE_CAPACITY; i++) { gValid[i] = true; } for (i = 0; i < PALETTE_COLORS; i++) { gPalette[i] = PALETTE_SENTINEL; } } // Every pixel of gSurface: blocks below tilesPasted must show the PNG's tile // (block % tileCount), every other block FILL_COLOR. static bool surfaceMatches(uint16_t tilesPasted) { int16_t x; int16_t y; uint16_t block; uint8_t want; uint8_t got; for (y = 0; y < JL_SURFACE_HEIGHT; y++) { for (x = 0; x < JL_SURFACE_WIDTH; x++) { block = (uint16_t)((y / JL_TILE_PIXELS_PER_SIDE) * JL_TILE_BLOCKS_PER_ROW + x / JL_TILE_PIXELS_PER_SIDE); want = FILL_COLOR; if (block < tilesPasted) { want = gExpectedPixels[(uint32_t)(block % gExpectedCount) * TILE_PIXELS + (y % JL_TILE_PIXELS_PER_SIDE) * JL_TILE_PIXELS_PER_SIDE + x % JL_TILE_PIXELS_PER_SIDE]; } got = jlSamplePixel(gSurface, x, y); if (got != want) { printf(" first mismatch at (%d,%d): got %u, want %u\n", x, y, got, want); return false; } } } return true; } static void testArgumentGuards(void) { resetBuffers(); check("args", "NULL path loads nothing", jlTileBankLoad(NULL, gTiles, TILE_CAPACITY, gValid, gPalette) == 0u); check("args", "NULL tile buffer loads nothing", jlTileBankLoad(GOOD_BANK, NULL, TILE_CAPACITY, gValid, gPalette) == 0u); check("args", "maxTiles 0 loads nothing", jlTileBankLoad(GOOD_BANK, gTiles, 0u, gValid, gPalette) == 0u); check("args", "no tile written by any of those", tilesUntouched(0u, TILE_CAPACITY)); check("args", "to-surface NULL path pastes nothing", jlTileBankLoadToSurface(NULL, gSurface, gPalette) == 0u); check("args", "to-surface NULL surface pastes nothing", jlTileBankLoadToSurface(GOOD_BANK, NULL, gPalette) == 0u); } static void testBigBank(void) { uint16_t loaded; uint16_t last; resetBuffers(); loaded = jlTileBankLoad(BIG_BANK, gTiles, TILE_CAPACITY, gValid, gPalette); check(BIG_BANK, "loads more tiles than a surface holds", loaded == BIG_BANK_TILES); check(BIG_BANK, "valid true for every tile", validRangeIs(0u, BIG_BANK_TILES, true)); pasteLoadedTiles(SURFACE_TILE_BLOCKS); check(BIG_BANK, "a surface-full of loaded tiles matches the PNG", surfaceMatches(SURFACE_TILE_BLOCKS)); last = BIG_BANK_TILES - 1u; check(BIG_BANK, "the tile past a surface-full matches the PNG", memcmp(&gTiles[last], &gTiles[last % gExpectedCount], sizeof(jlTileT)) == 0); jlSurfaceClear(gSurface, FILL_COLOR); loaded = jlTileBankLoadToSurface(BIG_BANK, gSurface, gPalette); check(BIG_BANK, "to-surface stops at a full surface", loaded == SURFACE_TILE_BLOCKS); check(BIG_BANK, "to-surface fills the surface from the PNG", surfaceMatches(SURFACE_TILE_BLOCKS)); } static void testGoodBank(void) { uint16_t loaded; resetBuffers(); loaded = jlTileBankLoad(GOOD_BANK, gTiles, TILE_CAPACITY, gValid, gPalette); check(GOOD_BANK, "loads every tile", loaded == gExpectedCount); check(GOOD_BANK, "valid true for the loaded slots", validRangeIs(0u, gExpectedCount, true)); check(GOOD_BANK, "valid false past the bank", validRangeIs(gExpectedCount, TILE_CAPACITY, false)); check(GOOD_BANK, "slots past the bank untouched", tilesUntouched(gExpectedCount, TILE_CAPACITY)); check(GOOD_BANK, "palette matches the PNG", paletteIsExpected()); pasteLoadedTiles(gExpectedCount); check(GOOD_BANK, "pasted tiles match the PNG", surfaceMatches(gExpectedCount)); resetBuffers(); loaded = jlTileBankLoad(GOOD_BANK, gTiles, TILE_CAPACITY, NULL, NULL); check(GOOD_BANK, "loads with NULL outValid and outPalette", loaded == gExpectedCount); } static void testMaxTiles(void) { uint16_t loaded; resetBuffers(); loaded = jlTileBankLoad(GOOD_BANK, gTiles, SMALL_MAX_TILES, gValid, gPalette); check(GOOD_BANK, "a small maxTiles stops the load there", loaded == SMALL_MAX_TILES); check(GOOD_BANK, "valid true up to maxTiles", validRangeIs(0u, SMALL_MAX_TILES, true)); check(GOOD_BANK, "valid past maxTiles untouched", validRangeIs(SMALL_MAX_TILES, TILE_CAPACITY, true)); check(GOOD_BANK, "tiles past maxTiles untouched", tilesUntouched(SMALL_MAX_TILES, TILE_CAPACITY)); pasteLoadedTiles(SMALL_MAX_TILES); check(GOOD_BANK, "the maxTiles tiles match the PNG", surfaceMatches(SMALL_MAX_TILES)); } static void testNoPalette(void) { uint16_t loaded; resetBuffers(); loaded = jlTileBankLoad(NO_PALETTE_BANK, gTiles, TILE_CAPACITY, gValid, gPalette); check(NO_PALETTE_BANK, "loads every tile", loaded == gExpectedCount); check(NO_PALETTE_BANK, "palette untouched", paletteUntouched()); jlSurfaceClear(gSurface, FILL_COLOR); loaded = jlTileBankLoadToSurface(NO_PALETTE_BANK, gSurface, gPalette); check(NO_PALETTE_BANK, "to-surface pastes every tile", loaded == gExpectedCount); check(NO_PALETTE_BANK, "to-surface palette untouched", paletteUntouched()); } // A refused bank loads nothing, clears outValid, and leaves the tiles, the // palette and the surface alone. static void testRefused(const char *bank) { uint16_t loaded; resetBuffers(); loaded = jlTileBankLoad(bank, gTiles, TILE_CAPACITY, gValid, gPalette); check(bank, "refused (loads nothing)", loaded == 0u); check(bank, "valid false everywhere", validRangeIs(0u, TILE_CAPACITY, false)); check(bank, "tiles untouched", tilesUntouched(0u, TILE_CAPACITY)); check(bank, "palette untouched", paletteUntouched()); jlSurfaceClear(gSurface, FILL_COLOR); loaded = jlTileBankLoadToSurface(bank, gSurface, gPalette); check(bank, "to-surface refused (pastes nothing)", loaded == 0u); check(bank, "to-surface surface untouched", surfaceMatches(0u)); check(bank, "to-surface palette untouched", paletteUntouched()); } static void testToSurface(void) { uint16_t loaded; resetBuffers(); jlSurfaceClear(gSurface, FILL_COLOR); loaded = jlTileBankLoadToSurface(GOOD_BANK, gSurface, gPalette); check(GOOD_BANK, "to-surface pastes every tile", loaded == gExpectedCount); check(GOOD_BANK, "to-surface palette matches the PNG", paletteIsExpected()); check(GOOD_BANK, "to-surface pixels match the PNG, later blocks keep theirs", surfaceMatches(gExpectedCount)); // scripts/verify--tilebank.sh read this line: each port's own bake, // drawn by examples/tilebank, must hash the same. printf(" info %s: to-surface hash %08lX (a port loading its own bake must match)\n", GOOD_BANK, (unsigned long)jlSurfaceHash(gSurface)); jlSurfaceClear(gSurface, FILL_COLOR); loaded = jlTileBankLoadToSurface(GOOD_BANK, gSurface, NULL); check(GOOD_BANK, "to-surface pastes with NULL outPalette", loaded == gExpectedCount); } static void testTruncated(void) { uint16_t loaded; resetBuffers(); loaded = jlTileBankLoad(TRUNCATED_BANK, gTiles, TILE_CAPACITY, gValid, gPalette); check(TRUNCATED_BANK, "loads only the whole tiles", loaded == TRUNCATED_TILES); check(TRUNCATED_BANK, "valid true for the whole tiles", validRangeIs(0u, TRUNCATED_TILES, true)); check(TRUNCATED_BANK, "valid false from the cut on", validRangeIs(TRUNCATED_TILES, TILE_CAPACITY, false)); pasteLoadedTiles(TRUNCATED_TILES); check(TRUNCATED_BANK, "the whole tiles match the PNG", surfaceMatches(TRUNCATED_TILES)); jlSurfaceClear(gSurface, FILL_COLOR); loaded = jlTileBankLoadToSurface(TRUNCATED_BANK, gSurface, gPalette); check(TRUNCATED_BANK, "to-surface pastes only the whole tiles", loaded == TRUNCATED_TILES); check(TRUNCATED_BANK, "to-surface leaves the cut tile's block alone", surfaceMatches(TRUNCATED_TILES)); } static bool tilesUntouched(uint16_t first, uint16_t end) { uint16_t i; uint16_t b; for (i = first; i < end; i++) { for (b = 0; b < JL_TILE_BYTES; b++) { if (gTiles[i].pixels[b] != TILE_SENTINEL) { return false; } } } return true; } static bool validRangeIs(uint16_t first, uint16_t end, bool want) { uint16_t i; for (i = first; i < end; i++) { if (gValid[i] != want) { return false; } } return true; } static bool writeBank(const char *name, uint32_t length) { FILE *fp; bool ok; fp = jlDataOpen(name, "wb"); if (fp == NULL) { return false; } ok = fwrite(gFile, 1, length, fp) == length; return (fclose(fp) == 0) && ok; } static void writeLe16(uint8_t *bytes, uint16_t value) { bytes[0] = (uint8_t)value; bytes[1] = (uint8_t)(value >> CHAR_BIT); } int main(int argc, char **argv) { jlConfigT config; int i; bool ready; printf("tilebank-host: jlTileBankLoad + jlTileBankLoadToSurface on the BLANK port (real files)\n"); memset(&config, 0, sizeof(config)); config.noAudio = true; if (!jlInit(&config)) { printf("tilebank-host: jlInit failed\n"); return 1; } gSurface = jlSurfaceCreate(); ready = gSurface != NULL; check("setup", "jlSurfaceCreate", ready); ready = ready && loadExpected(); check("setup", EXPECTED_FILE " read (fixture spans more than a block row, less than a surface)", ready); ready = ready && craftBanks(); check("setup", "malformed banks cut from " GOOD_BANK, ready); check("setup", "other targets' bakes to refuse were passed in", argc > 1); if (ready) { testArgumentGuards(); testGoodBank(); testMaxTiles(); testToSurface(); testTruncated(); testNoPalette(); testBigBank(); for (i = 1; i < argc; i++) { testRefused(argv[i]); } testRefused(BAD_MAGIC_BANK); testRefused(SHORT_HEADER_BANK); testRefused(MISSING_BANK); } jlSurfaceDestroy(gSurface); jlShutdown(); printf("tilebank-host: %u passed, %u failed\n", (unsigned)gPass, (unsigned)gFail); return (gFail == 0u) ? 0 : 1; }