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) <noreply@anthropic.com>
This commit is contained in:
commit
2287f3be12
4 changed files with 449 additions and 0 deletions
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
51
README.md
Normal file
51
README.md
Normal file
|
|
@ -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)
|
||||
<game files> original data — supplied by you, gitignored
|
||||
```
|
||||
73
docs/FORMATS.md
Normal file
73
docs/FORMATS.md
Normal file
|
|
@ -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)
|
||||
<concatenated payloads> 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<id>.DAT` PCM segments, `PLST<id>.DAT` playlists, `FFCREATE.EXE` |
|
||||
| `SOUND.FF` | 65 | `WAVE<id>.DAT` sound effects, `FFCREATE.EXE` |
|
||||
| `OBJECTS.FF` | 526 | `ASND<id>.DAT` … (enemy/pickup models + object sounds — format TBD) |
|
||||
| `INTRFACE.FF` | 547 | `BitB<id>.dat` cockpit/HUD/menu bitmaps — format TBD |
|
||||
|
||||
## Music: `WAVE` segments + `PLST` playlists ✅
|
||||
|
||||
- `WAVE<id>.DAT` — standard `RIFF`/`WAVE`, PCM **mono, 22050 Hz, 8-bit**.
|
||||
- `PLST<id>.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<id>` 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 `<NN>` = 01..06 plus `N`/`N2` variants (biomes: desert, snow/ice, lava, ocean).
|
||||
|
||||
| Pattern | Guess | Status |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `STUF<lvl>.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 |
|
||||
282
tools/ff_extract.py
Normal file
282
tools/ff_extract.py
Normal file
|
|
@ -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
|
||||
<concatenated file data; each entry's size = next_offset - this_offset>
|
||||
|
||||
MUSIC.FF additionally contains:
|
||||
WAVE<grp><n>.DAT RIFF WAVE PCM segments (mono 22050Hz 8-bit, typically)
|
||||
PLST<grp>.DAT playlists that sequence the WAVE<grp>* 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("<I", data, 0)
|
||||
pos = 4
|
||||
raw = []
|
||||
for _ in range(count):
|
||||
(off,) = struct.unpack_from("<I", data, pos); pos += 4
|
||||
end = data.index(b"\x00", pos)
|
||||
name = data[pos:end].decode("latin1")
|
||||
pos = end + 1
|
||||
raw.append((name, off))
|
||||
out = []
|
||||
for i, (name, off) in enumerate(raw):
|
||||
nxt = raw[i + 1][1] if i + 1 < len(raw) else len(data)
|
||||
out.append((name, off, nxt - off))
|
||||
return out
|
||||
|
||||
|
||||
def wav_parts(buf):
|
||||
"""Return (fmt_chunk_bytes, data_bytes) from a RIFF WAVE buffer."""
|
||||
assert buf[:4] == b"RIFF" and buf[8:12] == b"WAVE", "not a RIFF WAVE"
|
||||
pos = 12
|
||||
fmt = data = None
|
||||
while pos + 8 <= len(buf):
|
||||
cid = buf[pos:pos + 4]
|
||||
(csz,) = struct.unpack_from("<I", buf, pos + 4)
|
||||
body = buf[pos + 8: pos + 8 + csz]
|
||||
if cid == b"fmt ":
|
||||
fmt = body
|
||||
elif cid == b"data":
|
||||
data = body
|
||||
pos += 8 + csz + (csz & 1) # chunks are word-aligned
|
||||
return fmt, data
|
||||
|
||||
|
||||
def build_wav(fmt, data):
|
||||
riff_len = 4 + (8 + len(fmt)) + (8 + len(data))
|
||||
out = bytearray()
|
||||
out += b"RIFF" + struct.pack("<I", riff_len) + b"WAVE"
|
||||
out += b"fmt " + struct.pack("<I", len(fmt)) + fmt
|
||||
out += b"data" + struct.pack("<I", len(data)) + data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def wave_by_id(blobs):
|
||||
"""hex-id (e.g. 0x07D1) -> 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("<HHIIHH", fmt, 0)
|
||||
secs = len(dat) / (sr * ch * bits / 8)
|
||||
out_name = f"WAVE_{wid:04X}.wav"
|
||||
with open(os.path.join(samp_dir, out_name), "wb") as fh:
|
||||
fh.write(build_wav(fmt, dat))
|
||||
note_of[wid] = note
|
||||
ticks_of[wid] = round(secs * tps)
|
||||
file_of[wid] = out_name
|
||||
rows.append((wid, note, out_name, round(secs, 3)))
|
||||
|
||||
with open(os.path.join(outdir, "mapping.csv"), "w") as fh:
|
||||
fh.write("wave_id,midi_note,sample_file,seconds\n")
|
||||
for wid, note, fn, secs in rows:
|
||||
fh.write(f"{wid:04X},{note},{fn},{secs}\n")
|
||||
|
||||
n_songs = 0
|
||||
for name in sorted(blobs):
|
||||
if not name.upper().startswith("PLST"):
|
||||
continue
|
||||
base = int(name[4:8], 16)
|
||||
pl = blobs[name]
|
||||
seg_count, step_count = 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_<id>.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_<name>)")
|
||||
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()
|
||||
Loading…
Reference in a new issue