"""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()