feat: crack GRAFIX0N.DAT texture format + extractor

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
This commit is contained in:
pyr0ball 2026-07-03 09:36:41 -07:00
parent 27174a1c79
commit 198977ed2b
3 changed files with 128 additions and 2 deletions

View file

@ -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).

View file

@ -146,9 +146,43 @@ Music track ID is stored at **offset 18 as big-endian uint16** in each `LAND*.DA
|---|---|---|
| `MAP<lvl>.MAP` | tile/heightmap grid, 1 byte per cell | layout confirmed (byte grid); dims TBD |
| `LAND<lvl>.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<lvl>.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 |

91
tools/grafix_extract.py Normal file
View file

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