joeylib2/tools/joeysprite/joeysprite.c

706 lines
23 KiB
C

// 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 <ctype.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}