joeylib2/tests/host/tileBankHost.c

508 lines
19 KiB
C

// 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.py 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<n>.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 colour 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 <limits.h>
#include <stdio.h>
#include <string.h>
#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.py).
#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 colour; 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-<port>-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;
}