// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // // assetbake.c -- bake indexed PNG assets into JoeyLib's native binary // formats: // * Tile banks (.tbk) are per target: plane-major on the Amiga and the // X68000, a row's four plane bytes together on the ST, chunky 4bpp on // DOS and the IIgs (the layout each port's tile paste reads). // * Sprite cels (.spr) are chunky 4bpp for every target: the sprite // walker converts at draw time. // Both embed the PNG palette's first 16 entries as $0RGB. // // Source PNGs must be indexed with pixel dimensions that are multiples of // 8. Tile banks take one 8x8 tile a grid cell, row by row; sprite sheets // one cel a grid cell, the cel size given in tiles with --cell WxH. // // assetbake --type tile --target amiga|atarist|dos|iigs|x68000 in.png out.tbk // assetbake --type sprite --cell WxH in.png out.spr // // .tbk: "JTB1", target (1 amiga, 2 atarist, 3 dos, 4 iigs, 5 x68000), // hasPalette, tileCount (LE16), 4 reserved, 16 LE16 $0RGB palette words, // then 32 bytes a tile. // .spr: "JSP1", target 0, hasPalette, widthTiles, heightTiles, cellCount // (LE16), 2 reserved, the palette, then each cel's tiles left to right, top // to bottom, 8 rows of 4 chunky bytes each (high nibble the left pixel). #include #include #include #include "toolFile.h" #include "toolPng.h" #define TOOL_NAME "assetbake" #define MAGIC_BYTES 4u #define TARGET_CROSS_CHUNKY 0u #define TARGET_AMIGA 1u #define TARGET_ATARIST 2u #define TARGET_DOS 3u #define TARGET_IIGS 4u #define TARGET_X68000 5u #define TILE_PX 8u #define TILE_BYTES 32u #define PLANES 4u #define PALETTE_ENTRIES 16u #define HEADER_BYTES 44u #define HEADER_PALETTE_AT 12u #define MAX_COUNT 0xFFFFu #define NIBBLE_MASK 0x0Fu #define NIBBLE_SHIFT 4u #define USAGE_EXIT 2 typedef void (*TileEncoderT)(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out); typedef struct TargetT { const char *name; uint8_t id; TileEncoderT encoder; } TargetT; static void encodeTileChunky(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out); static void encodeTilePlaneMajor(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out); static void encodeTileRowPlanes(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out); static void headerWrite(uint8_t *header, const char *magic, const ToolImageT *img); static void loadIndexedPng(const char *path, ToolImageT *img); static const char *optionValue(int argc, char **argv, int *a, const char *name); static void parseCell(const char *spec, uint32_t *w, uint32_t *h); static uint8_t planeByte(const ToolImageT *img, uint32_t ox, uint32_t y, uint32_t plane); static void usage(const char *message); static void writeOutput(const char *path, const uint8_t *header, const uint8_t *body, size_t bodyLen); static const TargetT kTargets[] = { { "amiga", TARGET_AMIGA, encodeTilePlaneMajor }, { "atarist", TARGET_ATARIST, encodeTileRowPlanes }, { "dos", TARGET_DOS, encodeTileChunky }, { "iigs", TARGET_IIGS, encodeTileChunky }, { "x68000", TARGET_X68000, encodeTilePlaneMajor } }; #define TARGET_COUNT (sizeof(kTargets) / sizeof(kTargets[0])) // 8 rows of 4 bytes, the high nibble the left pixel. static void encodeTileChunky(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out) { const uint8_t *row; uint32_t r; uint32_t c; for (r = 0; r < TILE_PX; r++) { row = img->pixels + (size_t)(oy + r) * img->width + ox; for (c = 0; c < TILE_PX; c += 2u) { *out++ = (uint8_t)(((row[c] & NIBBLE_MASK) << NIBBLE_SHIFT) | (row[c + 1u] & NIBBLE_MASK)); } } } // The Amiga and the X68000: plane 0's 8 row bytes, then plane 1's, ... static void encodeTilePlaneMajor(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out) { uint32_t k; uint32_t r; for (k = 0; k < PLANES; k++) { for (r = 0; r < TILE_PX; r++) { *out++ = planeByte(img, ox, oy + r, k); } } } // The ST: each row's plane bytes 0..3 together. static void encodeTileRowPlanes(const ToolImageT *img, uint32_t ox, uint32_t oy, uint8_t *out) { uint32_t r; uint32_t k; for (r = 0; r < TILE_PX; r++) { for (k = 0; k < PLANES; k++) { *out++ = planeByte(img, ox, oy + r, k); } } } // The header both formats share: the magic, the PNG palette's first 16 // entries as LE16 $0RGB (an entry the PNG lacks is 0), and the caller's // fields at 4..11. An indexed PNG always has a palette. static void headerWrite(uint8_t *header, const char *magic, const ToolImageT *img) { uint16_t word; uint32_t i; memset(header, 0, HEADER_BYTES); memcpy(header, magic, MAGIC_BYTES); for (i = 0; i < PALETTE_ENTRIES && i < img->paletteCount; i++) { word = (uint16_t)((img->palette[i][0] >> NIBBLE_SHIFT) << 8 | (img->palette[i][1] >> NIBBLE_SHIFT) << 4 | (img->palette[i][2] >> NIBBLE_SHIFT)); header[HEADER_PALETTE_AT + i * 2u] = (uint8_t)word; header[HEADER_PALETTE_AT + i * 2u + 1u] = (uint8_t)(word >> 8); } } static void loadIndexedPng(const char *path, ToolImageT *img) { char err[TOOL_PNG_ERROR_BYTES]; if (!toolPngLoad(path, img, err)) { toolFail("%s: %s", path, err); } if (!img->indexed) { toolFail("%s is not an indexed PNG, need 'P' (indexed)", path); } if (img->width % TILE_PX != 0u || img->height % TILE_PX != 0u) { toolFail("%s dims %ux%u not 8-px-tile multiples", path, img->width, img->height); } } // The value of option `name` at argv[*a] (`--name value` or `--name=value`), // *a stepped past it; NULL when argv[*a] is not that option. static const char *optionValue(int argc, char **argv, int *a, const char *name) { size_t len; len = strlen(name); if (strncmp(argv[*a], name, len) != 0) { return NULL; } if (argv[*a][len] == '=') { return argv[*a] + len + 1; } if (argv[*a][len] != '\0') { return NULL; } if (*a + 1 >= argc) { usage("an option needs a value"); } *a += 1; return argv[*a]; } // "WxH" in tiles, as the Python read it: exactly one x (either case), and // two integers, both positive. static void parseCell(const char *spec, uint32_t *w, uint32_t *h) { const char *x; long width; long height; x = strpbrk(spec, "xX"); if (x == NULL || strpbrk(x + 1, "xX") != NULL) { toolFail("bad --cell '%s', need 'WxH'", spec); } if (!toolParseInt(spec, (size_t)(x - spec), &width) || !toolParseInt(x + 1, strlen(x + 1), &height)) { toolFail("bad --cell '%s', non-integer", spec); } if (width <= 0 || height <= 0) { toolFail("bad --cell '%s', zero/negative", spec); } *w = (uint32_t)width; *h = (uint32_t)height; } // One plane's bits of the 8 pixels from (ox, y), the leftmost in bit 7. static uint8_t planeByte(const ToolImageT *img, uint32_t ox, uint32_t y, uint32_t plane) { const uint8_t *row; uint32_t c; uint8_t b; row = img->pixels + (size_t)y * img->width + ox; b = 0u; for (c = 0; c < TILE_PX; c++) { if (((row[c] >> plane) & 1u) != 0u) { b = (uint8_t)(b | (0x80u >> c)); } } return b; } // The command line, the targets read from kTargets (scripts read them from // here: tests/host/tileBankStage.sh). static void usage(const char *message) { size_t i; fprintf(stderr, "usage: %s --type {sprite,tile} [--target {", TOOL_NAME); for (i = 0; i < TARGET_COUNT; i++) { fprintf(stderr, "%s%s", i == 0u ? "" : ",", kTargets[i].name); } fprintf(stderr, "}] [--cell WxH] input output\n"); fprintf(stderr, "%s: error: %s\n", TOOL_NAME, message); exit(USAGE_EXIT); } static void writeOutput(const char *path, const uint8_t *header, const uint8_t *body, size_t bodyLen) { FILE *f; bool ok; f = fopen(path, "wb"); if (f == NULL) { toolFail("cannot write %s", path); } ok = fwrite(header, 1, HEADER_BYTES, f) == HEADER_BYTES && fwrite(body, 1, bodyLen, f) == bodyLen; if (fclose(f) != 0 || !ok) { toolFail("cannot write %s", path); } } int main(int argc, char **argv) { ToolImageT img; const TargetT *target; const char *type; const char *targetName; const char *cell; const char *paths[2]; const char *value; uint8_t header[HEADER_BYTES]; uint8_t *body; uint8_t *out; size_t bodyLen; uint32_t pathCount; uint32_t count; uint32_t cellW; uint32_t cellH; uint32_t cx; uint32_t cy; uint32_t tx; uint32_t ty; uint32_t i; int a; toolFileName(TOOL_NAME); type = NULL; targetName = NULL; cell = NULL; pathCount = 0u; for (a = 1; a < argc; a++) { value = optionValue(argc, argv, &a, "--type"); if (value != NULL) { type = value; continue; } value = optionValue(argc, argv, &a, "--target"); if (value != NULL) { targetName = value; continue; } value = optionValue(argc, argv, &a, "--cell"); if (value != NULL) { cell = value; continue; } if (argv[a][0] == '-' && argv[a][1] != '\0') { usage("unrecognized arguments"); } if (pathCount == 2u) { usage("unrecognized arguments"); } paths[pathCount++] = argv[a]; } if (type == NULL || pathCount < 2u) { usage("the following arguments are required: --type, input, output"); } if (strcmp(type, "tile") != 0 && strcmp(type, "sprite") != 0) { usage("argument --type: invalid choice"); } target = NULL; if (targetName != NULL) { for (i = 0; i < TARGET_COUNT; i++) { if (strcmp(targetName, kTargets[i].name) == 0) { target = &kTargets[i]; } } if (target == NULL) { usage("argument --target: invalid choice"); } } if (strcmp(type, "tile") == 0) { if (target == NULL) { toolFail("--type tile requires --target"); } loadIndexedPng(paths[0], &img); count = (img.width / TILE_PX) * (img.height / TILE_PX); if (count > MAX_COUNT) { toolFail("%s %u tiles exceeds 65535", paths[0], count); } headerWrite(header, "JTB1", &img); header[4] = target->id; header[5] = 1u; header[6] = (uint8_t)count; header[7] = (uint8_t)(count >> 8); bodyLen = (size_t)count * TILE_BYTES; body = (uint8_t *)malloc(bodyLen > 0u ? bodyLen : 1u); if (body == NULL) { toolFail("out of memory"); } out = body; for (ty = 0; ty < img.height / TILE_PX; ty++) { for (tx = 0; tx < img.width / TILE_PX; tx++) { target->encoder(&img, tx * TILE_PX, ty * TILE_PX, out); out += TILE_BYTES; } } } else { if (cell == NULL) { toolFail("--type sprite requires --cell WxH"); } parseCell(cell, &cellW, &cellH); loadIndexedPng(paths[0], &img); if (img.width % (cellW * TILE_PX) != 0u || img.height % (cellH * TILE_PX) != 0u) { toolFail("%s %ux%u not multiple of cell %ux%u", paths[0], img.width, img.height, cellW * TILE_PX, cellH * TILE_PX); } count = (img.width / (cellW * TILE_PX)) * (img.height / (cellH * TILE_PX)); if (count > MAX_COUNT) { toolFail("%s %u cels exceeds 65535", paths[0], count); } headerWrite(header, "JSP1", &img); header[4] = TARGET_CROSS_CHUNKY; header[5] = 1u; header[6] = (uint8_t)cellW; header[7] = (uint8_t)cellH; header[8] = (uint8_t)count; header[9] = (uint8_t)(count >> 8); bodyLen = (size_t)count * cellW * cellH * TILE_BYTES; body = (uint8_t *)malloc(bodyLen > 0u ? bodyLen : 1u); if (body == NULL) { toolFail("out of memory"); } out = body; for (cy = 0; cy < img.height / (cellH * TILE_PX); cy++) { for (cx = 0; cx < img.width / (cellW * TILE_PX); cx++) { for (ty = 0; ty < cellH; ty++) { for (tx = 0; tx < cellW; tx++) { encodeTileChunky(&img, (cx * cellW + tx) * TILE_PX, (cy * cellH + ty) * TILE_PX, out); out += TILE_BYTES; } } } } } writeOutput(paths[1], header, body, bodyLen); free(body); toolImageFree(&img); return 0; }