diff --git a/README.md b/README.md index fd89d62..b703566 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ quality-of-life remaster. - [x] Extracted `MUSIC.FF`: 97 WAV segments + 17 playlist-sequenced songs (verified correct). - [x] FL Studio pack: per-song MIDI + sample mapping (`tools/ff_extract.py --fl`). - [ ] ogg/mp3 conversion (`--convert`, needs ffmpeg). -- [ ] Crack graphics formats: `GRAFIX*.DAT`, `LAND*.DAT`, `MAP*.MAP`, `STUF*.DAT`. +- [x] Boot `HAVOC.EXE` on Windows 11 (dgVoodoo2) — NOCD + copy-protection fix, see [docs/PATCHES.md](docs/PATCHES.md). +- [~] Crack graphics formats: `GRAFIX*.DAT` done ([docs/FORMATS.md](docs/FORMATS.md)); `LAND*.DAT`, `MAP*.MAP`, `STUF*.DAT` next. - [ ] Decompile `HAVOC.EXE` (Ghidra) to recover game logic. - [ ] Godot remaster project (widescreen, higher-res, rebindable input). diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 94e551d..9f3ed2c 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -146,9 +146,43 @@ Music track ID is stored at **offset 18 as big-endian uint16** in each `LAND*.DA |---|---|---| | `MAP.MAP` | tile/heightmap grid, 1 byte per cell | layout confirmed (byte grid); dims TBD | | `LAND.DAT` | terrain geometry/tile defs | TBD | -| `GRAFIX0N.DAT` | terrain/texture tilesets | TBD | +| `GRAFIX0N.DAT` | 64x64 texture tilesets | ✅ cracked, see below (`tools/grafix_extract.py`) | | `STUF.DAT` | entity placement (contains `Item` records) | TBD | +### `GRAFIX0N.DAT` — texture tileset ✅ implemented in `tools/grafix_extract.py` + +Big-endian (Mac-origin data, byte-swapped by the in-game loader at VA `0x444db0`). +`GRAFIX00.DAT` = 29 graphics: an explosion sprite (id 901, 8 frames), a smoke sprite +(id 911, 8 frames), and 27 single-frame 64x64 structure/wall textures (ids 8001-8028: +metal panels, hazard stripes, warning bars, arrows, circuit patterns). + +``` +Header: + 0x00 8 bytes reserved (0) + 0x08 u32 BE total file size + 0x0c u16 BE entry count N + 0x20 N*16 entry records +Entry record (16 bytes): + 0x00 u32 BE absolute file offset of the graphic block + 0x04 u16 BE id + 0x06 10 bytes reserved +Graphic block (at record offset): + 0x00 u16 BE width (64) + 0x02 u16 BE height (64) + 0x04 u16 BE frame count + 0x06 u16 BE id + 0x08 8 bytes reserved + 0x10 w*h*frames bytes 8-bit palette indices +``` + +Pixels are 8-bit indices into a 256-colour palette (768-byte RGB, e.g. the `XPAL` +palettes in `INTRFACE.FF`). Index 1 (magenta `ff 00 ff`) is the transparency key. +The exact world palette per tileset is still TBD; UI `XPAL0000` renders them plausibly. + +```sh +python tools/grafix_extract.py WORLDS/GRAFIX00.DAT -p tools/out_intrface/raw/XPAL0000.DAT +``` + ## Other top-level data — TODO | File | Notes | diff --git a/tools/grafix_extract.py b/tools/grafix_extract.py new file mode 100644 index 0000000..93bf7e8 --- /dev/null +++ b/tools/grafix_extract.py @@ -0,0 +1,91 @@ +"""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()