#!/usr/bin/env python3 # assetbake.py -- bake indexed PNG assets into JoeyLib native binary # formats. Replaces the .jas pipeline (png2jas + the runtime asset # loader's chunky->planar conversion path). # # Each target's runtime can use the baked bytes directly: # * Tile banks (.tbk) are per-target planar (or chunky for DOS/IIgs). # The Phase 11 walker reads sprite tile data as chunky 4bpp, but # tile-paste is platform-private -- Amiga is plane-major, ST is # row-major-with-planes-per-row, DOS / IIgs are chunky 4bpp. # * Sprite cels (.spr) are cross-target chunky 4bpp. The sprite walker # reads chunky and does c2p inline at draw time, so the same blob # works on every platform. # # Both formats embed an optional 16-entry $0RGB palette so the runtime # can pin tile / sprite colors without depending on the stage palette # state. # # Source PNGs must be 'P' (indexed) mode with at most 16 colors and # pixel dimensions that are multiples of 8 (tile size). Tile banks # extract one 8x8 tile per grid cell row-major. Sprite sheets extract # one cel per grid cell, where cel size is given via --cell WxH (in # tile units, not pixels). # # -------------------------------------------------------------------- # .tbk format # -------------------------------------------------------------------- # offset bytes field # 0 4 magic "JTB1" # 4 1 target (1=amiga, 2=atarist, 3=dos, 4=iigs) # 5 1 hasPalette (0 or 1) # 6 2 tileCount (LE16) # 8 4 reserved (zero) # 12 32 palette[16] LE16 $0RGB (zeros if hasPalette==0) # 44 ... tileCount * 32 bytes, layout per target: # amiga : plane-major [p0_row0..7][p1_row0..7]... # atarist: row-major-planes-per-row # [r0_p0 r0_p1 r0_p2 r0_p3][r1_p0..] # dos : chunky 4bpp [r0_b0..3][r1_b0..3]... # iigs : chunky 4bpp (same as dos) # # -------------------------------------------------------------------- # .spr format # -------------------------------------------------------------------- # offset bytes field # 0 4 magic "JSP1" # 4 1 target (0 = cross-platform chunky 4bpp) # 5 1 hasPalette # 6 1 widthTiles (cel width in 8-px tiles) # 7 1 heightTiles (cel height in 8-px tiles) # 8 2 cellCount (LE16) # 10 2 reserved (zero) # 12 32 palette[16] LE16 $0RGB (zeros if hasPalette==0) # 44 ... cellCount * (widthTiles * heightTiles * 32) bytes # per cel: tile-major (tile(0,0), tile(1,0), ..., # tile(W-1,0), tile(0,1), ...). Per tile: 8 rows of # 4 chunky 4bpp bytes, high nibble = left pixel. import argparse import struct import sys from PIL import Image MAGIC_TBK = b"JTB1" MAGIC_SPR = b"JSP1" TARGET_CROSS_CHUNKY = 0 TARGET_AMIGA = 1 TARGET_ATARIST = 2 TARGET_DOS = 3 TARGET_IIGS = 4 TARGET_NAMES = { "amiga": TARGET_AMIGA, "atarist": TARGET_ATARIST, "dos": TARGET_DOS, "iigs": TARGET_IIGS, } TILE_PX = 8 # 8x8 tile TILE_BYTES = 32 # 4bpp packed = 4 bytes/row * 8 rows PALETTE_BYTES = 32 # 16 entries * 2 bytes LE HEADER_BYTES = 44 # both formats share header length # ----- PNG helpers ---------------------------------------------------- def loadIndexedPng(path): img = Image.open(path) if img.mode != "P": sys.exit(f"assetbake: {path} mode={img.mode}, need 'P' (indexed)") w, h = img.size if w <= 0 or h <= 0: sys.exit(f"assetbake: {path} zero-sized") if (w % TILE_PX) != 0 or (h % TILE_PX) != 0: sys.exit(f"assetbake: {path} dims {w}x{h} not 8-px-tile multiples") px = img.load() grid = [[px[x, y] for x in range(w)] for y in range(h)] return grid, w, h, img.getpalette() def packPaletteRgb444(rawPalette): # PNG palette is RGB triples (256 entries). Pack first 16 into # $0RGB little-endian 16-bit words (4 bits per channel). out = bytearray() if rawPalette is None: return bytes(PALETTE_BYTES) for i in range(16): base = i * 3 if base + 2 < len(rawPalette): r = rawPalette[base ] >> 4 g = rawPalette[base + 1] >> 4 b = rawPalette[base + 2] >> 4 else: r = g = b = 0 word = (r << 8) | (g << 4) | b out += struct.pack("> k) & 1: b |= (1 << (7 - c)) out.append(b) return bytes(out) def encodeTileRowPlanesPerRow(grid, ox, oy): # ST: 8 rows, each row is 4 plane bytes in order [p0 p1 p2 p3]. out = bytearray() for r in range(TILE_PX): row = grid[oy + r] for k in range(4): b = 0 for c in range(TILE_PX): if (row[ox + c] >> k) & 1: b |= (1 << (7 - c)) out.append(b) return bytes(out) TILE_ENCODERS = { TARGET_AMIGA: encodeTilePlaneMajor, TARGET_ATARIST: encodeTileRowPlanesPerRow, TARGET_DOS: encodeTileChunky, TARGET_IIGS: encodeTileChunky, } # ----- Bakers --------------------------------------------------------- def bakeTileBank(srcPath, dstPath, target): grid, w, h, rawPal = loadIndexedPng(srcPath) tilesAcross = w // TILE_PX tilesDown = h // TILE_PX tileCount = tilesAcross * tilesDown if tileCount > 0xFFFF: sys.exit(f"assetbake: {srcPath} {tileCount} tiles exceeds 65535") encoder = TILE_ENCODERS[target] palette = packPaletteRgb444(rawPal) hasPal = 1 if rawPal is not None else 0 header = bytearray() header += MAGIC_TBK header += struct.pack(" 0xFFFF: sys.exit(f"assetbake: {srcPath} {cellCount} cels exceeds 65535") palette = packPaletteRgb444(rawPal) hasPal = 1 if rawPal is not None else 0 header = bytearray() header += MAGIC_SPR header += struct.pack(" (W, H) in tile units. parts = spec.lower().split("x") if len(parts) != 2: sys.exit(f"assetbake: bad --cell '{spec}', need 'WxH'") try: w = int(parts[0]) h = int(parts[1]) except ValueError: sys.exit(f"assetbake: bad --cell '{spec}', non-integer") if w <= 0 or h <= 0: sys.exit(f"assetbake: bad --cell '{spec}', zero/negative") return w, h def main(): p = argparse.ArgumentParser(description="Bake JoeyLib native assets.") p.add_argument("--type", required=True, choices=["sprite", "tile"]) p.add_argument("--target", choices=list(TARGET_NAMES.keys()), help="required for --type tile") p.add_argument("--cell", help="cel size as WxH in tile units (sprite only)") p.add_argument("input") p.add_argument("output") args = p.parse_args() if args.type == "tile": if args.target is None: sys.exit("assetbake: --type tile requires --target") bakeTileBank(args.input, args.output, TARGET_NAMES[args.target]) else: if args.cell is None: sys.exit("assetbake: --type sprite requires --cell WxH") wT, hT = parseCell(args.cell) bakeSpriteSheet(args.input, args.output, wT, hT) if __name__ == "__main__": main()