From 2287f3be12d96d4ff4bd8f73f94d0967ff9d69b3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 28 Jun 2026 23:33:30 -0700 Subject: [PATCH] Initial commit: Havoc preservation toolkit + format docs - ff_extract.py: .FF (FlashFile) archive extractor with music song assembly (WAVE+PLST), ogg/mp3 conversion, and FL Studio pack export (samples + per-song MIDI + mapping). - docs/FORMATS.md: reverse-engineered file formats (.FF, WAVE/PLST music, world data + binary notes). - Original game data is gitignored (copyright Reality Bytes, 1995). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 43 +++++++ README.md | 51 ++++++++ docs/FORMATS.md | 73 ++++++++++++ tools/ff_extract.py | 282 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 449 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/FORMATS.md create mode 100644 tools/ff_extract.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d29bfaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# --- Original game data (copyright Reality Bytes, Inc. 1995) — DO NOT COMMIT --- +*.EXE +*.exe +*.DLL +*.dll +*.VXD +*.FF +*.DAT +*.MAP +*.ICO +*.INF +.windows-serial +BIGFILE.DAT +WORLDS/ +DIRECTX/ +LICENSE.TXT + +# --- Derived audio / extracted assets (also copyrighted) --- +tools/out_*/ +tools/Havoc_*/ +*.wav +*.ogg +*.mp3 +*.mid +*.zip + +# --- Python / tooling cruft --- +__pycache__/ +*.pyc +.venv/ +venv/ + +# --- Godot --- +.godot/ +*.import +export_presets.cfg + +# --- Editor / OS --- +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd89d62 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Havoc — Preservation & Godot Remaster + +Reverse-engineering, asset-extraction, and Godot remaster of **Havoc** (Reality Bytes, Inc., +1995) — a first-person **vehicular-combat shooter** for Windows 95/98. + +Havoc renders a **software-rasterized 3D world** (textured terrain, low-poly enemy/pickup +models, drivable *and* flyable vehicles, dithered 8-bit color) into a DirectDraw framebuffer, +framed by an elaborate **2D cockpit HUD** (mirrors, radar, gauges, weapon icons). Biomes +include desert, snow/ice, lava, and ocean. + +The original 32-bit executable (DirectDraw + DirectSound, **no Direct3D** — the 3D is all +software) no longer runs reliably on modern Windows (legacy DirectDraw + a 16-bit +installer/DirectX setup path). Rather than only emulate it, this project extracts the original +data into open formats and rebuilds the game in [Godot](https://godotengine.org/) as a +quality-of-life remaster. + +> **Legal:** Havoc is 30-year-old abandonware. This repository contains **only** original +> reverse-engineering code and documentation. It does **not** redistribute the game's +> executables, DLLs, or data files (see `.gitignore`). You must supply your own copy of the +> game data to run the tools. + +## Status + +- [x] Identified binaries: `HAVOC.EXE`/`LAUNCHER.EXE`/`SETUP.EXE` are 32-bit PE (i386), + DirectDraw + DirectSound, **not** 16-bit/DOS. +- [x] Cracked the `.FF` ("FlashFile") archive format — see [docs/FORMATS.md](docs/FORMATS.md). +- [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`. +- [ ] Decompile `HAVOC.EXE` (Ghidra) to recover game logic. +- [ ] Godot remaster project (widescreen, higher-res, rebindable input). + +## Tools + +`tools/ff_extract.py` — extract any `.FF` archive. + +```sh +python tools/ff_extract.py MUSIC.FF -o out_music --songs # raw entries + assembled songs +python tools/ff_extract.py MUSIC.FF -o out_music --songs --convert ogg +python tools/ff_extract.py MUSIC.FF -o Havoc_MUSIC_FLStudio --fl # FL Studio samples + MIDI +``` + +## Layout + +``` +tools/ reverse-engineering & extraction scripts (tracked) +docs/ format documentation (tracked) +godot/ Godot remaster project (tracked, WIP) + original data — supplied by you, gitignored +``` diff --git a/docs/FORMATS.md b/docs/FORMATS.md new file mode 100644 index 0000000..dc10d33 --- /dev/null +++ b/docs/FORMATS.md @@ -0,0 +1,73 @@ +# Havoc file formats (reverse-engineered) + +All offsets/sizes verified against the retail data set (Reality Bytes, Inc., 1995). + +## Binaries + +| File | Type | Notes | +|---|---|---| +| `HAVOC.EXE` | PE32 i386, GUI | Imports `DDRAW`, `DSOUND`, `WINMM`, `WSOCK32`, `GDI32`, `USER32`, `KERNEL32`. **No Direct3D** — 3D is a software rasterizer drawing into a DirectDraw surface. Contains a CD-presence check ("...Extra Player CD..."). | +| `LAUNCHER.EXE` | PE32 i386, GUI | Front-end menu (see `havoc_9` screenshot). | +| `SETUP.EXE` | PE32 i386, GUI | Config/setup. | +| `DIRECTX/` | DX5-era | Ships 16-bit `DSETUP16.DLL`/`DDRAW16.DLL` — the 16-bit installer path is what breaks on 64-bit Windows, not the game itself. | + +## `.FF` — "FlashFile" archive ✅ implemented in `tools/ff_extract.py` + +Used by `MUSIC.FF`, `SOUND.FF`, `OBJECTS.FF`, `INTRFACE.FF`. Built by the bundled DOS tool +`FFCREATE.EXE` (found inside `MUSIC.FF`/`SOUND.FF`). + +``` +uint32 file_count (little-endian) +repeat file_count: + uint32 absolute_offset (little-endian, from start of .FF) + char[] name (NUL-terminated, variable length) + entry size = next_offset - this_offset +``` + +Notes: +- The table may contain a trailing empty-name entry — skip it. +- Entry payloads are raw and self-describing (e.g. `RIFF`/`WAVE`, `MZ`, bitmaps). + +### Archive contents + +| Archive | Count | Entry kinds | +|---|---|---| +| `MUSIC.FF` | 116 | `WAVE.DAT` PCM segments, `PLST.DAT` playlists, `FFCREATE.EXE` | +| `SOUND.FF` | 65 | `WAVE.DAT` sound effects, `FFCREATE.EXE` | +| `OBJECTS.FF` | 526 | `ASND.DAT` … (enemy/pickup models + object sounds — format TBD) | +| `INTRFACE.FF` | 547 | `BitB.dat` cockpit/HUD/menu bitmaps — format TBD | + +## Music: `WAVE` segments + `PLST` playlists ✅ + +- `WAVE.DAT` — standard `RIFF`/`WAVE`, PCM **mono, 22050 Hz, 8-bit**. +- `PLST.DAT` — a song = an ordered sequence of WAVE segments. **Payload is big-endian** + (note: the `.FF` table around it is little-endian — mixed by design): + +``` +uint16 segment_count (big-endian; how many WAVE segments belong to this group) +uint16 step_count (big-endian) +uint16[step_count] sequence (big-endian; 1-based segment index) +``` + +Segment resolution: a playlist named `PLST` has base id = `int(id, 16)`; **step value N +maps to wave id `base + N - 1`** (e.g. `PLST07D1` step 2 → `WAVE07D2`). Concatenating the +referenced segments' PCM reproduces the song exactly (verified by ear). + +## World data (`WORLDS/`) — TODO (3D terrain) + +Per-level sets `` = 01..06 plus `N`/`N2` variants (biomes: desert, snow/ice, lava, ocean). + +| Pattern | Guess | Status | +|---|---|---| +| `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 | +| `STUF.DAT` | entity placement (contains `Item` records) | TBD | + +## Other top-level data — TODO + +| File | Notes | +|---|---| +| `BIGFILE.DAT` (64 MB) | starts `Copyright 1995, Reality Bytes, Inc.` then embedded `RIFF` data — bulk audio/stream pool | +| `STRDATA.DAT` | length-prefixed UI string table (`Out of memory.`, `Not enough disk space.`, …) | +| `HAVOC.ICO` | 32×32 4-bit icon | diff --git a/tools/ff_extract.py b/tools/ff_extract.py new file mode 100644 index 0000000..dafb41b --- /dev/null +++ b/tools/ff_extract.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +ff_extract.py - Extract Havoc ".FF" (FlashFile) archives. + +The .FF format (used by MUSIC.FF, SOUND.FF, OBJECTS.FF, INTRFACE.FF): + + uint32 file_count + repeat file_count times: + uint32 absolute_offset_into_file + char[] name, NUL-terminated + + +MUSIC.FF additionally contains: + WAVE.DAT RIFF WAVE PCM segments (mono 22050Hz 8-bit, typically) + PLST.DAT playlists that sequence the WAVE* segments into a song: + uint16 segment_count (how many WAVE segments in the group, 1-based) + uint16 step_count + uint16[step_count] sequence (1-based indices into the group's segments) + +Usage: + python ff_extract.py MUSIC.FF -o out_music # dump every entry raw + python ff_extract.py MUSIC.FF -o out_music --songs # also assemble songs from playlists + python ff_extract.py MUSIC.FF -o out_music --songs --convert ogg # needs ffmpeg on PATH +""" +import argparse, os, struct, shutil, subprocess +from collections import defaultdict + + +def parse_ff(data): + """Return list of (name, offset, size).""" + (count,) = struct.unpack_from(" wave name. A playlist's segment N maps to base_id + N - 1.""" + out = {} + for name in blobs: + if name.upper().startswith("WAVE"): + out[int(name[4:8], 16)] = name + return out + + +def extract(ff_path, outdir, make_songs, convert): + data = open(ff_path, "rb").read() + entries = parse_ff(data) + os.makedirs(outdir, exist_ok=True) + + blobs = {} + raw_dir = os.path.join(outdir, "raw") + os.makedirs(raw_dir, exist_ok=True) + for name, off, size in entries: + safe = name.strip().replace("/", "_").replace("\\", "_") + if not safe: + continue # skip empty/terminator TOC entries + blob = data[off:off + size] + blobs[safe] = blob + with open(os.path.join(raw_dir, safe), "wb") as fh: + fh.write(blob) + print(f"[{os.path.basename(ff_path)}] {len(entries)} entries -> {raw_dir}") + + songs = [] + if make_songs: + song_dir = os.path.join(outdir, "songs") + os.makedirs(song_dir, exist_ok=True) + waves = wave_by_id(blobs) + for name in sorted(blobs): + if not name.upper().startswith("PLST"): + continue + grp = name[4:8] + base = int(grp, 16) + pl = blobs[name] + seg_count, step_count = struct.unpack_from(">HH", pl, 0) # PLST payload is big-endian + seq = struct.unpack_from(">%dH" % step_count, pl, 4) + fmt0 = None + pcm = bytearray() + ok = True + for idx in seq: + seg_name = waves.get(base + idx - 1) + if seg_name is None: + print(f" ! {name}: missing segment {idx} (wave id {base + idx - 1:04X})") + ok = False + break + fmt, dat = wav_parts(blobs[seg_name]) + if fmt0 is None: + fmt0 = fmt + pcm += dat + if not ok or fmt0 is None: + continue + song_wav = os.path.join(song_dir, f"SONG_{grp}.wav") + with open(song_wav, "wb") as fh: + fh.write(build_wav(fmt0, bytes(pcm))) + songs.append(song_wav) + print(f" song {grp}: {step_count} steps -> {os.path.basename(song_wav)}") + + if convert: + ff = shutil.which("ffmpeg") + if not ff: + print("\n! ffmpeg not found on PATH; skipping conversion.") + print(" Install with: scoop install ffmpeg (or) choco install ffmpeg") + return + targets = songs if (make_songs and songs) else [ + os.path.join(raw_dir, n) for n in blobs if blobs[n][:4] == b"RIFF" + ] + conv_dir = os.path.join(outdir, convert) + os.makedirs(conv_dir, exist_ok=True) + codec = {"ogg": ["-c:a", "libvorbis", "-q:a", "5"], + "mp3": ["-c:a", "libmp3lame", "-q:a", "2"]}[convert] + for src in targets: + base = os.path.splitext(os.path.basename(src))[0] + dst = os.path.join(conv_dir, base + "." + convert) + subprocess.run([ff, "-y", "-loglevel", "error", "-i", src, *codec, dst], check=True) + print(f"\nConverted {len(targets)} file(s) -> {conv_dir}") + + +def _varlen(n): + """MIDI variable-length quantity.""" + out = bytearray([n & 0x7F]) + n >>= 7 + while n: + out.insert(0, (n & 0x7F) | 0x80) + n >>= 7 + return bytes(out) + + +def _smf(notes, ppq=480, bpm=120): + """Build a format-0 SMF from [(note, ticks)] played back-to-back, monophonic.""" + trk = bytearray() + # tempo meta + us = int(60_000_000 / bpm) + trk += _varlen(0) + bytes([0xFF, 0x51, 0x03]) + us.to_bytes(3, "big") + for note, ticks in notes: + trk += _varlen(0) + bytes([0x90, note, 100]) # note on + trk += _varlen(max(1, ticks)) + bytes([0x80, note, 0]) # note off after duration + trk += _varlen(0) + bytes([0xFF, 0x2F, 0x00]) # end of track + head = b"MThd" + (6).to_bytes(4, "big") + (0).to_bytes(2, "big") + \ + (1).to_bytes(2, "big") + ppq.to_bytes(2, "big") + return head + b"MTrk" + len(trk).to_bytes(4, "big") + bytes(trk) + + +def build_flstudio(ff_path, outdir, ppq=480, bpm=120): + """Sample pack (all WAVE segments as .wav) + per-song MIDI reconstructing each playlist, + with a single global note->sample mapping so one FL sampler covers every song.""" + data = open(ff_path, "rb").read() + entries = parse_ff(data) + blobs = {n: data[o:o + s] for n, o, s in entries if n.strip()} + + samp_dir = os.path.join(outdir, "samples") + midi_dir = os.path.join(outdir, "midi") + os.makedirs(samp_dir, exist_ok=True) + os.makedirs(midi_dir, exist_ok=True) + + # global mapping: every WAVE id -> a unique MIDI note (base C1=24), + duration in ticks + wave_ids = sorted(int(n[4:8], 16) for n in blobs if n.upper().startswith("WAVE")) + BASE = 24 + tps = ppq * bpm / 60.0 # ticks per second + note_of, ticks_of, file_of = {}, {}, {} + rows = [] + for i, wid in enumerate(wave_ids): + name = next(n for n in blobs if n.upper().startswith("WAVE") and int(n[4:8], 16) == wid) + note = BASE + i + fmt, dat = wav_parts(blobs[name]) + af, ch, sr, br, ba, bits = struct.unpack_from("HH", pl, 0) + seq = struct.unpack_from(">%dH" % step_count, pl, 4) + notes, ok = [], True + for idx in seq: + wid = base + idx - 1 + if wid not in note_of: + ok = False; break + notes.append((note_of[wid], ticks_of[wid])) + if not ok: + continue + with open(os.path.join(midi_dir, f"SONG_{name[4:8]}.mid"), "wb") as fh: + fh.write(_smf(notes, ppq, bpm)) + n_songs += 1 + + readme = os.path.join(outdir, "README_FLStudio.txt") + with open(readme, "w") as fh: + fh.write( + "Havoc music -> FL Studio pack\n" + "=============================\n\n" + f"samples/ {len(rows)} WAVE segments (PCM mono 22050Hz 8-bit), named WAVE_.wav\n" + f"midi/ {n_songs} songs, each a MIDI that triggers samples in the original order\n" + "mapping.csv wave_id -> MIDI note -> sample file -> length(s)\n\n" + "How to use:\n" + "1. Add a sampler that maps keys chromatically starting at C1 (MIDI note 24):\n" + " - DirectWave or Slicex: drop all samples/ in, set root keys per mapping.csv, or\n" + " - Channel rack: load each sample on its own channel and key it per mapping.csv.\n" + "2. Import a midi/SONG_*.mid into the playlist; it plays segments back-to-back\n" + " exactly as the game sequenced them. Tempo is a neutral 120 BPM; note lengths\n" + " equal each sample's real duration, so playback matches the assembled song.\n" + "3. Remix freely - rearrange segments, layer, repitch, etc.\n" + ) + # zip it + import zipfile + zpath = os.path.join(os.path.dirname(outdir) or ".", + os.path.basename(outdir.rstrip("/\\")) + ".zip") + with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z: + for root, _, files in os.walk(outdir): + for f in files: + full = os.path.join(root, f) + z.write(full, os.path.relpath(full, outdir)) + print(f"FL pack: {len(rows)} samples, {n_songs} MIDI songs -> {outdir}") + print(f"Zipped -> {zpath}") + + +def main(): + ap = argparse.ArgumentParser(description="Extract Havoc .FF archives") + ap.add_argument("ff", help="path to a .FF archive") + ap.add_argument("-o", "--out", default=None, help="output dir (default: out_)") + ap.add_argument("--songs", action="store_true", help="assemble songs from PLST playlists") + ap.add_argument("--convert", choices=["ogg", "mp3"], help="convert to ogg/mp3 (needs ffmpeg)") + ap.add_argument("--fl", action="store_true", help="build FL Studio pack (samples + MIDI + zip)") + a = ap.parse_args() + out = a.out or ("out_" + os.path.splitext(os.path.basename(a.ff))[0].lower()) + if a.fl: + build_flstudio(a.ff, out) + else: + extract(a.ff, out, a.songs, a.convert) + + +if __name__ == "__main__": + main()