// joeysprite: host-side compiler that turns sprite art into a `.spr` // file ready to be loaded at runtime by spriteLoadFile. // // Usage: // joeysprite --target {iigs,amiga,atarist,dos} // [--width-tiles N --height-tiles M] // INPUT OUTPUT.spr // // Two input formats are accepted; the first 2 bytes select the path: // // PPM (P6) -- 8-bit-per-channel raster from any pixel-art tool that // exports PPM (GIMP, ImageMagick `convert`, paint.net, etc.). Image // dimensions must be multiples of 8 in both axes; widthTiles / // heightTiles are auto-derived as W/8 and H/8 (CLI overrides are // optional and must match). Each input RGB is reduced to a 12-bit // $0RGB color (high nibble of each channel); the input must use // no more than 16 distinct $0RGB colors after that reduction. The // FIRST color encountered (typically the top-left pixel) is bound // to palette index 0, which the runtime treats as transparent -- // so paint your sprite background with that pixel's color. // // Raw `.tiles` -- widthTiles * heightTiles * 32 bytes, laid out // tile-major as the runtime SpriteT.tileData expects: tile (0,0) // first 32 bytes, tile (1,0) next 32, ... tile (widthTiles-1, 0), // then tile (0,1), and so on. Inside each tile, rows are stored // top-to-bottom and each row is 4 bytes (8 pixels at 4bpp packed, // high nibble = left pixel). --width-tiles / --height-tiles are // required for this path since the file carries no header. // // The .spr output carries indices only -- the palette mapping is the // application's responsibility (typical pattern: ship a separate // .jas built from the same PPM via joeyasset, or hand-author the // palette in code). // // Output `.spr` format (target-native byte order for code; see // DESIGN.md ยง12). Mirrors src/core/sprite.c's reader: // byte 0 widthTiles // byte 1 heightTiles // bytes 2-3 codeSize (LE16) // bytes 4-5 tileBytes (LE16) = widthTiles*heightTiles*32 // ... offsets (JOEY_SPRITE_SHIFT_COUNT * SPRITE_OP_COUNT * // uint16_t LE): [draw_s0, save_s0, restore_s0, // draw_s1, save_s1, restore_s1]. Each entry is the // byte offset of that routine within the compiled-code // region, or 0xFFFF (SPRITE_NOT_COMPILED) if the per-CPU // emitter returned 0 bytes for that op -- the runtime // then falls back to the interpreted memcpy/RMW path. // ... compiled code (codeSize bytes) // ... raw tile data (tileBytes bytes; same layout as the // input file, lets the runtime interpreter handle // clipped draws without decoding the compiled bytes). #include #include #include #include #include #include #include #include "joey/sprite.h" #include "spriteEmitter.h" #include "spriteInternal.h" typedef enum { TARGET_IIGS, TARGET_AMIGA, TARGET_ATARIST, TARGET_DOS, TARGET_INVALID } TargetE; // ----- Constants ----- #define MAX_SCRATCH_BYTES (16u * 1024u) // Pixel art conventions for sprite work. #define TILE_PIXELS 8 #define TILE_BYTES 32 #define TILE_BYTES_PER_ROW 4 #define MAX_PALETTE_ENTRIES 16 #define PPM_TOKEN_MAX 64 // ----- Prototypes ----- static int buildPalette(const uint8_t *rgb, int width, int height, uint8_t *outIndices); static int compileToSpr(const SpriteT *sp, TargetE target, const char *outPath); static uint16_t emitForTarget(uint8_t *out, const SpriteT *sp, uint8_t shift, uint8_t op, TargetE target); static bool fileIsPpm(const char *path); static int loadPpm(const char *path, int *outWidth, int *outHeight, uint8_t **outPixels); static int loadPpmAsTiles(const char *path, long *widthTiles, long *heightTiles, uint8_t **outTiles, uint32_t *outSize); static int loadTileData(const char *path, uint8_t **outBytes, uint32_t *outSize); static void packIndicesToTiles(const uint8_t *indices, int width, int height, uint8_t *outTiles); static int parsePpmToken(FILE *fp, char *out, int outLen); static TargetE parseTarget(const char *name); static int usage(const char *prog); static int writeLE16(FILE *fp, uint16_t v); // ----- Internal helpers (alphabetical) ----- // Reduce every input RGB triple to a 12-bit $0RGB color and assign // palette indices in encounter order: top-left pixel = index 0, // next-encountered = index 1, etc. The runtime treats index 0 as // transparent, so the top-left pixel must be the sprite's background // color. Returns the number of distinct colors found, or -1 if the // image needs more than 16 entries after $0RGB quantization. // // Mirrors joeyasset's buildPalette but only emits the index array; // joeysprite drops the $0RGB palette since the .spr format carries // indices alone. static int buildPalette(const uint8_t *rgb, int width, int height, uint8_t *outIndices) { uint16_t palette[MAX_PALETTE_ENTRIES]; int paletteCount; int total; int i; int j; uint8_t r; uint8_t g; uint8_t b; uint16_t color; total = width * height; paletteCount = 0; for (i = 0; i < total; i++) { r = (uint8_t)(rgb[i * 3 + 0] >> 4); g = (uint8_t)(rgb[i * 3 + 1] >> 4); b = (uint8_t)(rgb[i * 3 + 2] >> 4); color = (uint16_t)((r << 8) | (g << 4) | b); for (j = 0; j < paletteCount; j++) { if (palette[j] == color) { break; } } if (j == paletteCount) { if (paletteCount >= MAX_PALETTE_ENTRIES) { return -1; } palette[paletteCount] = color; paletteCount++; } outIndices[i] = (uint8_t)j; } return paletteCount; } // Two-pass: pass 1 sizes every (shift, op) routine into shiftOpSizes; // pass 2 stamps them into the code buffer at their cumulative offsets. // Routines that return 0 bytes (the per-CPU emitter doesn't implement // that op) get SPRITE_NOT_COMPILED in their offset slot so the runtime // dispatch falls back to the interpreted path. static int compileToSpr(const SpriteT *sp, TargetE target, const char *outPath) { uint8_t *scratch; uint8_t *codeBuf; uint16_t routineSizes[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT]; uint16_t routineOffsets[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT]; uint32_t totalCodeSize; uint8_t shift; uint8_t op; uint16_t written; uint16_t cursor; uint16_t value; FILE *fp; int rc; scratch = (uint8_t *)malloc(MAX_SCRATCH_BYTES); if (scratch == NULL) { fprintf(stderr, "joeysprite: out of memory\n"); return 2; } totalCodeSize = 0; for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { for (op = 0; op < SPRITE_OP_COUNT; op++) { written = emitForTarget(scratch, sp, shift, op, target); routineSizes[shift][op] = written; if (written == 0) { routineOffsets[shift][op] = SPRITE_NOT_COMPILED; } else { routineOffsets[shift][op] = (uint16_t)totalCodeSize; totalCodeSize += written; } } } if (totalCodeSize > 0xFFFFu) { fprintf(stderr, "joeysprite: emitted %u code bytes; max is 65535\n", (unsigned)totalCodeSize); free(scratch); return 2; } codeBuf = (uint8_t *)malloc(totalCodeSize > 0 ? totalCodeSize : 1); if (codeBuf == NULL) { fprintf(stderr, "joeysprite: out of memory for code buffer\n"); free(scratch); return 2; } cursor = 0; for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { for (op = 0; op < SPRITE_OP_COUNT; op++) { if (routineSizes[shift][op] == 0) { continue; } written = emitForTarget(codeBuf + cursor, sp, shift, op, target); cursor = (uint16_t)(cursor + written); } } fp = fopen(outPath, "wb"); if (fp == NULL) { fprintf(stderr, "joeysprite: cannot open %s for writing\n", outPath); free(codeBuf); free(scratch); return 2; } rc = 0; if (fputc(sp->widthTiles, fp) == EOF) rc = 2; if (rc == 0 && fputc(sp->heightTiles, fp) == EOF) rc = 2; if (rc == 0 && writeLE16(fp, (uint16_t)totalCodeSize) != 0) rc = 2; if (rc == 0 && writeLE16(fp, (uint16_t)(sp->widthTiles * sp->heightTiles * 32u)) != 0) rc = 2; for (shift = 0; rc == 0 && shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { for (op = 0; op < SPRITE_OP_COUNT; op++) { value = routineOffsets[shift][op]; if (writeLE16(fp, value) != 0) { rc = 2; break; } } } if (rc == 0 && totalCodeSize > 0) { if (fwrite(codeBuf, 1, totalCodeSize, fp) != totalCodeSize) { rc = 2; } } if (rc == 0) { // Append the raw tile data so the runtime interpreter has it // available for clipped draws. uint32_t tileBytes = (uint32_t)sp->widthTiles * (uint32_t)sp->heightTiles * 32u; if (sp->tileData == NULL) { fprintf(stderr, "joeysprite: sprite missing tile data, cannot save\n"); rc = 2; } else if (fwrite(sp->tileData, 1, tileBytes, fp) != tileBytes) { rc = 2; } } fclose(fp); free(codeBuf); free(scratch); if (rc == 0) { printf("joeysprite: %u code bytes -> %s (target=%s, %ux%u tiles)\n", (unsigned)totalCodeSize, outPath, target == TARGET_IIGS ? "iigs" : target == TARGET_AMIGA ? "amiga" : target == TARGET_ATARIST ? "atarist" : "dos", sp->widthTiles, sp->heightTiles); } return rc; } static uint16_t emitForTarget(uint8_t *out, const SpriteT *sp, uint8_t shift, uint8_t op, TargetE target) { switch (target) { case TARGET_DOS: switch (op) { case SPRITE_OP_DRAW: return spriteEmitDrawX86 (out, sp, shift); case SPRITE_OP_SAVE: return spriteEmitSaveX86 (out, sp, shift); case SPRITE_OP_RESTORE: return spriteEmitRestoreX86(out, sp, shift); default: return 0; } case TARGET_AMIGA: case TARGET_ATARIST: switch (op) { case SPRITE_OP_DRAW: return spriteEmitDraw68k (out, sp, shift); case SPRITE_OP_SAVE: return spriteEmitSave68k (out, sp, shift); case SPRITE_OP_RESTORE: return spriteEmitRestore68k(out, sp, shift); default: return 0; } case TARGET_IIGS: switch (op) { case SPRITE_OP_DRAW: return spriteEmitDrawIigs (out, sp, shift); case SPRITE_OP_SAVE: return spriteEmitSaveIigs (out, sp, shift); case SPRITE_OP_RESTORE: return spriteEmitRestoreIigs(out, sp, shift); default: return 0; } default: return 0; } } // Sniff the first 2 bytes for the PPM magic. Errors return false (the // caller will fall through to the .tiles loader, which surfaces a // clear error if the bytes aren't valid tile data either). static bool fileIsPpm(const char *path) { FILE *fp; int c0; int c1; fp = fopen(path, "rb"); if (fp == NULL) { return false; } c0 = fgetc(fp); c1 = fgetc(fp); fclose(fp); return (c0 == 'P' && c1 == '6'); } // Read a PPM (P6) raster into a freshly allocated 8-bit RGB buffer. // Mirrors joeyasset's loadPpm. Caller frees *outPixels. static int loadPpm(const char *path, int *outWidth, int *outHeight, uint8_t **outPixels) { FILE *fp; char tok[PPM_TOKEN_MAX]; int width; int height; int maxval; size_t pixelBytes; uint8_t *buf; size_t read; fp = fopen(path, "rb"); if (fp == NULL) { fprintf(stderr, "joeysprite: cannot open %s: %s\n", path, strerror(errno)); return 2; } if (parsePpmToken(fp, tok, sizeof(tok)) != 0 || strcmp(tok, "P6") != 0) { fprintf(stderr, "joeysprite: %s is not a PPM (P6) file\n", path); fclose(fp); return 2; } if (parsePpmToken(fp, tok, sizeof(tok)) != 0) { fclose(fp); return 2; } width = atoi(tok); if (parsePpmToken(fp, tok, sizeof(tok)) != 0) { fclose(fp); return 2; } height = atoi(tok); if (parsePpmToken(fp, tok, sizeof(tok)) != 0) { fclose(fp); return 2; } maxval = atoi(tok); if (width <= 0 || height <= 0) { fprintf(stderr, "joeysprite: %s has non-positive dimensions\n", path); fclose(fp); return 2; } if (maxval != 255) { fprintf(stderr, "joeysprite: %s maxval %d unsupported (must be 255)\n", path, maxval); fclose(fp); return 2; } pixelBytes = (size_t)width * (size_t)height * 3u; buf = (uint8_t *)malloc(pixelBytes); if (buf == NULL) { fprintf(stderr, "joeysprite: out of memory (%zu bytes)\n", pixelBytes); fclose(fp); return 2; } read = fread(buf, 1, pixelBytes, fp); fclose(fp); if (read != pixelBytes) { fprintf(stderr, "joeysprite: short raster in %s (got %zu, need %zu)\n", path, read, pixelBytes); free(buf); return 2; } *outWidth = width; *outHeight = height; *outPixels = buf; return 0; } // End-to-end PPM -> tile-major 4bpp packed. On entry, *widthTiles / // *heightTiles are 0 if the user didn't pass --width-tiles / // --height-tiles, or the user-provided values otherwise; we fill in // the auto-derived values when the user left them at 0, and validate // against the image when they didn't. static int loadPpmAsTiles(const char *path, long *widthTiles, long *heightTiles, uint8_t **outTiles, uint32_t *outSize) { uint8_t *rgb; uint8_t *indices; uint8_t *tiles; int width; int height; long wTiles; long hTiles; uint32_t tileBytes; int paletteCount; int rc; rc = loadPpm(path, &width, &height, &rgb); if (rc != 0) { return rc; } if ((width % TILE_PIXELS) != 0 || (height % TILE_PIXELS) != 0) { fprintf(stderr, "joeysprite: %s is %dx%d -- both dimensions must be multiples of %d\n", path, width, height, TILE_PIXELS); free(rgb); return 2; } wTiles = width / TILE_PIXELS; hTiles = height / TILE_PIXELS; if (*widthTiles == 0) { *widthTiles = wTiles; } else if (*widthTiles != wTiles) { fprintf(stderr, "joeysprite: --width-tiles %ld disagrees with image width %d (%ld tiles)\n", *widthTiles, width, wTiles); free(rgb); return 2; } if (*heightTiles == 0) { *heightTiles = hTiles; } else if (*heightTiles != hTiles) { fprintf(stderr, "joeysprite: --height-tiles %ld disagrees with image height %d (%ld tiles)\n", *heightTiles, height, hTiles); free(rgb); return 2; } indices = (uint8_t *)malloc((size_t)width * (size_t)height); if (indices == NULL) { fprintf(stderr, "joeysprite: out of memory for index buffer\n"); free(rgb); return 2; } paletteCount = buildPalette(rgb, width, height, indices); free(rgb); if (paletteCount < 0) { fprintf(stderr, "joeysprite: %s has more than 16 distinct $0RGB colors after\n" " 4-bit-per-channel quantization. Reduce the input palette and\n" " retry (e.g. pngquant --nofs 16, or GIMP -> Image -> Mode ->\n" " Indexed... with 16 colors and no dithering).\n", path); free(indices); return 2; } tileBytes = (uint32_t)wTiles * (uint32_t)hTiles * TILE_BYTES; tiles = (uint8_t *)malloc(tileBytes); if (tiles == NULL) { fprintf(stderr, "joeysprite: out of memory for tile buffer\n"); free(indices); return 2; } packIndicesToTiles(indices, width, height, tiles); free(indices); *outTiles = tiles; *outSize = tileBytes; return 0; } static int loadTileData(const char *path, uint8_t **outBytes, uint32_t *outSize) { FILE *fp; long fileSize; uint8_t *buf; size_t read; fp = fopen(path, "rb"); if (fp == NULL) { fprintf(stderr, "joeysprite: cannot open %s\n", path); return 2; } if (fseek(fp, 0L, SEEK_END) != 0) { fclose(fp); return 2; } fileSize = ftell(fp); if (fileSize <= 0) { fprintf(stderr, "joeysprite: %s is empty\n", path); fclose(fp); return 2; } if (fseek(fp, 0L, SEEK_SET) != 0) { fclose(fp); return 2; } buf = (uint8_t *)malloc((size_t)fileSize); if (buf == NULL) { fclose(fp); return 2; } read = fread(buf, 1, (size_t)fileSize, fp); fclose(fp); if (read != (size_t)fileSize) { free(buf); return 2; } *outBytes = buf; *outSize = (uint32_t)fileSize; return 0; } // Reshuffle row-major palette indices into the tile-major 4bpp packed // layout the runtime SpriteT.tileData expects: tile (tx,ty)'s 32 bytes // land contiguously at outTiles[(ty*widthTiles + tx) * 32], with each // row inside the tile as 4 packed bytes (high nibble = left pixel). static void packIndicesToTiles(const uint8_t *indices, int width, int height, uint8_t *outTiles) { int widthTiles; int heightTiles; int tx; int ty; int row; int col; int pxX; int pxY; uint8_t hi; uint8_t lo; uint8_t *tile; widthTiles = width / TILE_PIXELS; heightTiles = height / TILE_PIXELS; for (ty = 0; ty < heightTiles; ty++) { for (tx = 0; tx < widthTiles; tx++) { tile = &outTiles[(ty * widthTiles + tx) * TILE_BYTES]; for (row = 0; row < TILE_PIXELS; row++) { pxY = ty * TILE_PIXELS + row; for (col = 0; col < TILE_BYTES_PER_ROW; col++) { pxX = tx * TILE_PIXELS + col * 2; hi = (uint8_t)(indices[pxY * width + pxX] & 0x0Fu); lo = (uint8_t)(indices[pxY * width + pxX + 1] & 0x0Fu); tile[row * TILE_BYTES_PER_ROW + col] = (uint8_t)((hi << 4) | lo); } } } } } // Reads a single whitespace-separated token from a PPM header, // skipping `#` comments to end-of-line. Mirrors joeyasset. static int parsePpmToken(FILE *fp, char *out, int outLen) { int c; int pos; pos = 0; for (;;) { c = fgetc(fp); if (c == EOF) { return -1; } if (isspace(c)) { continue; } if (c == '#') { while ((c = fgetc(fp)) != EOF && c != '\n') { /* skip */; } continue; } break; } while (c != EOF && !isspace(c) && c != '#') { if (pos < outLen - 1) { out[pos++] = (char)c; } c = fgetc(fp); } out[pos] = 0; return 0; } static TargetE parseTarget(const char *name) { if (strcmp(name, "iigs") == 0) return TARGET_IIGS; if (strcmp(name, "amiga") == 0) return TARGET_AMIGA; if (strcmp(name, "atarist") == 0) return TARGET_ATARIST; if (strcmp(name, "dos") == 0) return TARGET_DOS; return TARGET_INVALID; } static int usage(const char *prog) { fprintf(stderr, "usage: %s --target {iigs,amiga,atarist,dos} \\\n" " [--width-tiles N --height-tiles M] \\\n" " INPUT OUTPUT.spr\n" " INPUT is a PPM (P6) file (auto-derives tile dims from W/8, H/8)\n" " or a raw .tiles byte stream (requires --width-tiles/--height-tiles).\n", prog); return 2; } // 65816 / x86 / 68k all expect target-native byte order in the .spr // header offsets, but the file format is little-endian (matches the // runtime spriteFromCompiledMem parser, which reads byte-by-byte). static int writeLE16(FILE *fp, uint16_t v) { if (fputc((int)(v & 0xFFu), fp) == EOF) return -1; if (fputc((int)((v >> 8) & 0xFFu), fp) == EOF) return -1; return 0; } // ----- main ----- int main(int argc, char **argv) { const char *targetName; const char *inPath; const char *outPath; long widthTiles; long heightTiles; int i; TargetE target; uint8_t *tileBytes; uint32_t tileSize; uint32_t expectedTileSize; SpriteT sp; int rc; targetName = NULL; widthTiles = 0; heightTiles = 0; inPath = NULL; outPath = NULL; for (i = 1; i < argc; i++) { if (strcmp(argv[i], "--target") == 0 && i + 1 < argc) { targetName = argv[++i]; } else if (strcmp(argv[i], "--width-tiles") == 0 && i + 1 < argc) { widthTiles = strtol(argv[++i], NULL, 10); } else if (strcmp(argv[i], "--height-tiles") == 0 && i + 1 < argc) { heightTiles = strtol(argv[++i], NULL, 10); } else if (inPath == NULL) { inPath = argv[i]; } else if (outPath == NULL) { outPath = argv[i]; } else { return usage(argv[0]); } } if (targetName == NULL || inPath == NULL || outPath == NULL) { return usage(argv[0]); } if (widthTiles < 0 || widthTiles > 255 || heightTiles < 0 || heightTiles > 255) { return usage(argv[0]); } target = parseTarget(targetName); if (target == TARGET_INVALID) { fprintf(stderr, "joeysprite: unknown --target %s\n", targetName); return usage(argv[0]); } if (fileIsPpm(inPath)) { // PPM path: tile dims auto-derive (or validate against CLI). rc = loadPpmAsTiles(inPath, &widthTiles, &heightTiles, &tileBytes, &tileSize); if (rc != 0) { return rc; } } else { // Raw .tiles path: tile dims required. if (widthTiles <= 0 || heightTiles <= 0) { fprintf(stderr, "joeysprite: %s is not a PPM; --width-tiles and --height-tiles are required\n", inPath); return usage(argv[0]); } rc = loadTileData(inPath, &tileBytes, &tileSize); if (rc != 0) { return rc; } } expectedTileSize = (uint32_t)(widthTiles * heightTiles * 32); if (tileSize != expectedTileSize) { fprintf(stderr, "joeysprite: %s is %u bytes; expected %u (%ld * %ld tiles * 32 bytes)\n", inPath, (unsigned)tileSize, (unsigned)expectedTileSize, widthTiles, heightTiles); free(tileBytes); return 2; } sp.tileData = tileBytes; sp.widthTiles = (uint8_t)widthTiles; sp.heightTiles = (uint8_t)heightTiles; sp.ownsTileData = false; sp.slot = NULL; memset(sp.routineOffsets, 0, sizeof(sp.routineOffsets)); sp.flags = SPRITE_FLAGS_NONE; rc = compileToSpr(&sp, target, outPath); free(tileBytes); return rc; }