WORLDS/GRAFIX0N.DAT is a big-endian (Mac-origin) tileset: an 8-byte header (file size at 0x08, entry count at 0x0c), 16-byte entry records (BE offset + id), and per-graphic blocks with a BE width/height/frames/id header followed by w*h*frames 8-bit palette indices. Confirmed against the in-game loader at 0x444db0 (16-byte record stride, BE->LE dword swap). GRAFIX00.DAT decodes to 29 graphics: explosion (id 901, 8 frames), smoke (id 911, 8 frames), and 27 single-frame 64x64 structure/wall textures (ids 8001-8028). tools/grafix_extract.py renders them to PNG with any 768-byte RGB palette (the XPAL palettes from INTRFACE.FF work). - tools/grafix_extract.py: parser + PNG renderer - docs/FORMATS.md: GRAFIX0N.DAT spec - README: status update Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
"""Extract Havoc WORLDS/GRAFIX*.DAT graphics.
|
|
|
|
Format (big-endian, Mac-origin):
|
|
Header:
|
|
0x00 8 bytes reserved (zero)
|
|
0x08 u32 BE total file size
|
|
0x0c u16 BE entry count (N)
|
|
0x0e ... reserved
|
|
0x20 N * 16 entry records
|
|
Entry record (16 bytes):
|
|
0x00 u32 BE absolute file offset of this graphic's block
|
|
0x04 u16 BE id
|
|
0x06 10 bytes reserved
|
|
Graphic block (at the record offset):
|
|
0x00 u16 BE width
|
|
0x02 u16 BE height
|
|
0x04 u16 BE frame count
|
|
0x06 u16 BE id
|
|
0x08 8 bytes reserved
|
|
0x10 width*height*frames bytes of 8-bit palette indices
|
|
|
|
Confirmed against the in-game loader at VA 0x444db0 (16-byte record stride,
|
|
BE->LE byte-swap of the record dwords).
|
|
"""
|
|
import struct, sys, os, argparse, glob
|
|
from PIL import Image
|
|
|
|
def load_palette(path):
|
|
d = open(path, 'rb').read()
|
|
assert len(d) >= 768, "palette must be >=768 bytes"
|
|
pal = list(d[:768])
|
|
return pal # 256 * (r,g,b), 8-bit
|
|
|
|
def parse_grafix(path):
|
|
d = open(path, 'rb').read()
|
|
size = struct.unpack_from('>I', d, 0x08)[0]
|
|
count = struct.unpack_from('>H', d, 0x0c)[0]
|
|
entries = []
|
|
for i in range(count):
|
|
o = 0x20 + i * 16
|
|
off = struct.unpack_from('>I', d, o)[0]
|
|
eid = struct.unpack_from('>H', d, o + 4)[0]
|
|
entries.append((off, eid))
|
|
graphics = []
|
|
for off, eid in entries:
|
|
w = struct.unpack_from('>H', d, off + 0)[0]
|
|
h = struct.unpack_from('>H', d, off + 2)[0]
|
|
frames = struct.unpack_from('>H', d, off + 4)[0]
|
|
bid = struct.unpack_from('>H', d, off + 6)[0]
|
|
px_start = off + 0x10
|
|
px = d[px_start: px_start + w * h * max(frames, 1)]
|
|
graphics.append(dict(off=off, id=bid, w=w, h=h, frames=frames, px=px))
|
|
return dict(size=size, count=count, graphics=graphics)
|
|
|
|
def render(graphics, pal, outdir, scale):
|
|
os.makedirs(outdir, exist_ok=True)
|
|
flatpal = pal if pal else [v for i in range(256) for v in (i, i, i)]
|
|
for g in graphics:
|
|
w, h, frames, bid = g['w'], g['h'], g['frames'], g['id']
|
|
for f in range(max(frames, 1)):
|
|
frame = g['px'][f * w * h:(f + 1) * w * h]
|
|
if len(frame) < w * h:
|
|
frame = frame + bytes(w * h - len(frame))
|
|
im = Image.frombytes('P', (w, h), bytes(frame))
|
|
im.putpalette(flatpal)
|
|
im = im.convert('RGB')
|
|
if scale > 1:
|
|
im = im.resize((w * scale, h * scale), Image.NEAREST)
|
|
suffix = f"_f{f}" if frames > 1 else ""
|
|
im.save(os.path.join(outdir, f"g{bid:05d}{suffix}.png"))
|
|
print(f" wrote {sum(max(g['frames'],1) for g in graphics)} PNGs to {outdir}")
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Extract Havoc GRAFIX*.DAT tiles")
|
|
ap.add_argument('grafix', help='GRAFIX*.DAT (or glob)')
|
|
ap.add_argument('-o', '--out', default=None)
|
|
ap.add_argument('-p', '--palette', default=None, help='768-byte RGB palette (.DAT)')
|
|
ap.add_argument('-s', '--scale', type=int, default=4)
|
|
args = ap.parse_args()
|
|
pal = load_palette(args.palette) if args.palette else None
|
|
for path in glob.glob(args.grafix):
|
|
info = parse_grafix(path)
|
|
base = os.path.splitext(os.path.basename(path))[0]
|
|
out = args.out or f"tools/out_grafix/{base}"
|
|
print(f"{path}: {info['count']} graphics, size 0x{info['size']:x}")
|
|
for g in info['graphics']:
|
|
print(f" id {g['id']:5d} {g['w']}x{g['h']} frames={g['frames']} @0x{g['off']:x}")
|
|
render(info['graphics'], pal, out, args.scale)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|