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:
parent
27174a1c79
commit
198977ed2b
3 changed files with 128 additions and 2 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
91
tools/grafix_extract.py
Normal 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()
|
||||
Loading…
Reference in a new issue