283 lines
9.7 KiB
Python
Executable file
283 lines
9.7 KiB
Python
Executable file
#!/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("<H", word)
|
|
return bytes(out)
|
|
|
|
|
|
# ----- Tile encoders --------------------------------------------------
|
|
|
|
def encodeTileChunky(grid, ox, oy):
|
|
# 8 rows of 4 bytes, high nibble = left pixel.
|
|
out = bytearray()
|
|
for r in range(TILE_PX):
|
|
row = grid[oy + r]
|
|
for c in range(0, TILE_PX, 2):
|
|
hi = row[ox + c ] & 0x0F
|
|
lo = row[ox + c + 1] & 0x0F
|
|
out.append((hi << 4) | lo)
|
|
return bytes(out)
|
|
|
|
|
|
def encodeTilePlaneMajor(grid, ox, oy):
|
|
# Amiga: 4 planes * 8 rows, one byte per row per plane.
|
|
# Plane 0 bytes 0..7, plane 1 bytes 8..15, etc.
|
|
out = bytearray()
|
|
for k in range(4):
|
|
for r in range(TILE_PX):
|
|
row = grid[oy + r]
|
|
b = 0
|
|
for c in range(TILE_PX):
|
|
if (row[ox + c] >> 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("<BBHI", target, hasPal, tileCount, 0)
|
|
header += palette
|
|
assert len(header) == HEADER_BYTES
|
|
|
|
body = bytearray()
|
|
for ty in range(tilesDown):
|
|
for tx in range(tilesAcross):
|
|
body += encoder(grid, tx * TILE_PX, ty * TILE_PX)
|
|
|
|
with open(dstPath, "wb") as f:
|
|
f.write(header)
|
|
f.write(body)
|
|
|
|
|
|
def bakeSpriteSheet(srcPath, dstPath, cellWTiles, cellHTiles):
|
|
grid, w, h, rawPal = loadIndexedPng(srcPath)
|
|
cellWPx = cellWTiles * TILE_PX
|
|
cellHPx = cellHTiles * TILE_PX
|
|
if (w % cellWPx) != 0 or (h % cellHPx) != 0:
|
|
sys.exit(f"assetbake: {srcPath} {w}x{h} not multiple of "
|
|
f"cell {cellWPx}x{cellHPx}")
|
|
cellsAcross = w // cellWPx
|
|
cellsDown = h // cellHPx
|
|
cellCount = cellsAcross * cellsDown
|
|
if cellCount > 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("<BBBB", TARGET_CROSS_CHUNKY, hasPal,
|
|
cellWTiles, cellHTiles)
|
|
header += struct.pack("<HH", cellCount, 0)
|
|
header += palette
|
|
assert len(header) == HEADER_BYTES
|
|
|
|
body = bytearray()
|
|
# Cels in PNG reading order: left-to-right, top-to-bottom.
|
|
# Each cel = tile-major chunky 4bpp.
|
|
for cy in range(cellsDown):
|
|
for cx in range(cellsAcross):
|
|
celOx = cx * cellWPx
|
|
celOy = cy * cellHPx
|
|
for ty in range(cellHTiles):
|
|
for tx in range(cellWTiles):
|
|
body += encodeTileChunky(grid,
|
|
celOx + tx * TILE_PX,
|
|
celOy + ty * TILE_PX)
|
|
|
|
with open(dstPath, "wb") as f:
|
|
f.write(header)
|
|
f.write(body)
|
|
|
|
|
|
# ----- CLI ------------------------------------------------------------
|
|
|
|
def parseCell(spec):
|
|
# "WxH" -> (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()
|