Ghidra decompilation environment stood up on Linux (headless, no sudo): JDK 21 + Ghidra 12.1.2 in ~/tools, reusing the existing analyzed project at tools/ghidra_project/ from the prior Windows session instead of starting fresh. FPS-coupling investigation: traced the main game loop's per-frame path and found the real root cause - compute_frame_delta_ticks_60hz_min1_clamp (0x4092D0) computes correct delta-time from timeGetTime(), but clamps the minimum return value to 1 tick even when zero ticks truly elapsed, causing simulation speed to scale with framerate above 60fps. First patch attempt (NOP the clamp) crashed on level load because the shared delta value has 203 read sites across 40+ functions and only 2 were checked for zero-safety; reverted and archived as a documented lesson. The corrected fix (busy-wait instead of lying) is designed but not yet built. Pause-loop hang fixed: the pause command handler blocks the Windows message queue in a tight GetAsyncKeyState polling loop (found while locating the cheat code handler), which trips modern Windows' "Not Responding" hang detection if left paused too long, permanently disconnecting input. Patched (HAVOC_NOCD_PAUSEFIX_v1.EXE) to pump one message per loop iteration via the game's own existing message-drain function; confirmed fixed by play-testing, including the related "alt-tab while paused resets to menu" symptom. A separate "alt-tab while not paused" reset symptom remains open. New tracking docs: docs/FUNCTIONS.md (function-by-function RE progress), docs/PATCH_VERSIONS.md (binary patch version log), docs/LEVEL_NOTES.md (level/AI design quirks for the Remaster variant). Extended docs/PATCHES.md and docs/METHODOLOGY.md with full discovery narratives including dead ends.
16 KiB
Havoc Remaster - Session Handoff
Read this first. It is a self-contained bootstrap for continuing the Havoc reverse-engineering
and remaster work in a fresh session (including on Linux). It assumes no prior conversation
memory. Companion docs: docs/PATCHES.md, docs/FORMATS.md, docs/METHODOLOGY.md,
docs/WEAPONS.md.
Last updated at the end of the session that got the game booting and cracked GRAFIX.
1. What this project is
Reviving Havoc (Reality Bytes, Inc., 1995): a first-person, software-rendered 3D vehicular-combat shooter for Win95/98. The binaries are 32-bit PE (i386), DirectDraw + DirectSound, no Direct3D (the 3D is a software rasterizer). It is NOT DOS/16-bit, so DOSBox is the wrong tool.
End goal: port to Godot as a remaster with QoL (widescreen, higher-res, rebindable input, fixed-timestep physics). Three planned variants:
- Faithful 1:1 (original bugs preserved as an option, including FPS-coupled cockpit physics).
- Remaster (bug fixes, widescreen, rebindable input, higher-res).
- Roguelite (procedural worlds, extra tanks) as a separate mode.
Two active work streams for the next sessions:
- A. Ghidra decompilation of
HAVOC.EXEto recover game logic (weapons, AI, physics, cheats, the FPS-coupling, level/entity loaders). - B. Asset cracking of the remaining
WORLDS/and.FFformats.
The user wants a detailed blog post of the whole RE process, so when you crack something,
write down the discovery steps and dead ends, not just the final spec. Keep notes in docs/.
2. Current state (what works / what is done)
- The game boots and is fully playable on Windows 11 via dgVoodoo2 (DirectDraw wrapper, windowed). Loads levels, enemies shoot, etc. This was the big unlock this session.
- NOCD + copy-protection fix:
HAVOC_NOCD.EXEis the patched original (28 patches). The final blocker was a copy-protection check in the data-file loader0x42a480that only accepted files loaded from the CD drive; a 14-byte patch makes it accept the local files. Full write-up and patch table indocs/PATCHES.md. - Formats cracked:
.FFarchives, MUSIC.FF (17 songs + FL Studio pack), INTRFACE.FF bitmaps, and now GRAFIX0N.DAT (64x64 texture tilesets,tools/grafix_extract.py). Seedocs/FORMATS.md. - MAP layout partially decoded:
MAP0101.MAPis a 2-byte-per-cell grid, ~192x144 (384-byte rows, found via row autocorrelation). Cell semantics (which byte is tile vs height) still TBD. - FPS-coupling: root cause found. It's not "no timestep at all" -- there's a real
delta-time system (
compute_frame_delta_ticks_60hz_min1_clamp@0x4092D0, convertstimeGetTime()to 60Hz ticks correctly), but it clamps its minimum return value to 1 tick. Above 60fps, less real time elapses between calls than one tick, so the clamp still credits a full tick -- simulation speed scales up in deterministic, linear proportion to render rate above that threshold (confirmed against the user's own description: "does not break ... deterministic ... behaves ... 'FASTER'"). Below/at 60fps the delta is accurate, which is exactly why dgVoodooFPSLimit=30fixes it. Whether this differs between cockpit and minimal-HUD modes is still unconfirmed -- the functions found so far gate only on game state, not HUD mode. Full chase incl. two dead ends indocs/METHODOLOGY.mdsection 10; function-by-function status indocs/FUNCTIONS.md.
3. Environment (Windows -> Linux)
The repo lives on navi at /Library/Development/devl/Havoc (mounted on the Windows box as
Z:\Development\devl\Havoc). On Linux you work directly in that path.
Tooling used this session and how it maps to Linux:
| Purpose | Windows (this session) | Linux equivalent |
|---|---|---|
| Python | C:/Python313/python.exe |
python3 |
| Disassembly | radare2 + r2pipe (pip) |
same: apt/pacman install radare2, pip install r2pipe |
| Image output | Pillow (pip install pillow) |
same |
| Decompiler | Ghidra 12.1.2 GUI (project at tools/ghidra_project/) |
Ghidra 12.1.2 headless (see below) - now set up |
| Run the game | dgVoodoo2 on native Windows | Wine + dgVoodoo (test only; not needed for static work) |
Windows-only tools that do NOT port (and are no longer needed): the crash diagnosis used WER
minidumps from %LOCALAPPDATA%\CrashDumps parsed in pure Python, and a ctypes Win32 debugger
(tools/dbg_path.py). Those were for the boot crash, which is solved. Decompilation and asset
cracking are pure static analysis and run fine on Linux.
All the extraction tools (ff_extract.py, grafix_extract.py) are cross-platform Python
(they use os.path, avoid Windows-isms).
Note: the game itself only runs on Windows (or Wine). Linux sessions are for Ghidra + asset extraction, which need only the files, not a running game.
Ghidra is now installed on Linux (Heimdall, headless, no sudo needed):
export JAVA_HOME=~/tools/jdk-21.0.11+10
export PATH=$JAVA_HOME/bin:$PATH
~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \
/Library/Development/devl/Havoc/tools/ghidra_project HAVOC \
-process HAVOC_NOCD.EXE -noanalysis \
-scriptPath /Library/Development/devl/Havoc/tools/ghidra_scripts \
-postScript DecompileHavoc.java
tools/ghidra_project/ is the existing, real, previously-analyzed project from an
earlier Windows/GUI session (gitignored, so invisible to git status - check the
filesystem). Do not create a new project. Full setup story (dead ends, the NotOwnerException
fix) is in docs/METHODOLOGY.md section 9. tools/ghidra_scripts/DecompileHavoc.java is a
working headless script that dumps decompiled C for a curated VA list to
tools/ghidra_decompile.txt - extend its TARGET_VAS array with new addresses as the
section 7 targets below get investigated. No GUI/display is available on this box; all
Ghidra work here is headless-only (scripting + batch decompile), not interactive
click-through.
Resolved: the project's binary was initially found to be a stale, superseded NOCD patch
iteration (MD5 mismatch vs. the current HAVOC_NOCD.EXE). Confirmed no manual annotation
existed to lose (0 of 987 functions user-named), so it was re-imported with -overwrite and
fully re-analyzed against the current binary. PrintProgramMD5.java (in tools/ ghidra_scripts/) confirms the project's EXECUTABLE_MD5 now matches md5sum HAVOC_NOCD.EXE exactly. tools/ghidra_decompile.txt has been regenerated against the
verified-current, working NOCD build. If HAVOC_NOCD.EXE gets repatched again, re-run the
-overwrite import (see docs/METHODOLOGY.md section 9 point 6) before trusting decompiled
output.
4. Repo, git, and constraints
- Remote: Forgejo, private repo
git.opensourcesolarpunk.com/pyr0ball/havoc-remaster(personalpyr0ballaccount, not the CircuitForge org). Origin is already configured. - Pushing: the user pushes on request. Token lives in a local
.env.mirrorfile (outside the repo) asFORGEJO_TOKEN- never print it, never store it in.git/config. Push viagit -c http.extraheader="Authorization: token $TOKEN" pushfor a one-time push. - Commits: solo repo, history is on
main. Commit style: conventional prefixes (feat:,docs:,chore:). Every commit message ends with the two trailer lines the harness provides (Co-Authored-By + Claude-Session).
Hard constraints (do not violate):
- Never commit original game data or binaries (copyright, Reality Bytes 1995):
*.EXE *.DLL *.FF *.DAT *.MAP *.INI *.cue *.bin,BIGFILE.DAT,WORLDS/,tmp_cd/, the Ghidra install/project, and derived audio/art are all in.gitignore. When staging, use explicit paths (notgit add -A) and sanity-checkgit diff --cached --name-only. - No emdashes in any output (user style preference). Use plain hyphens.
- Document discovery process for the blog (see section 1).
5. The binary: layout and key addresses
HAVOC_NOCD.EXE = patched HAVOC.EXE, 485,376 bytes, 32-bit PE i386, image base 0x400000,
loads at preferred base (so runtime VAs == static VAs).
Section file-offset (FO) to virtual-address (VA):
| Section | Raw FO | RVA | FO -> VA |
|---|---|---|---|
.text |
0x00400 | 0x01000 | VA = FO + 0x400C00 |
.rdata |
0x67000 | 0x79000 | VA = FO + 0x412000 |
.data |
0x69400 | 0x7C000 | VA = FO + 0x412C00 |
.idata |
0x6EC00 | 0x82000 | VA = FO + 0x413400 |
Control-flow map (verified this session):
| VA | What it is |
|---|---|
0x464657 |
CRT entry point |
0x407C90 |
WinMain. Allocs game object (vtable 0x4791A0), calls 0x434040 (init); on success calls vtable[7] = 0x43AC40 (main loop) |
0x434040 |
Main init: allocates ~30 subsystem objects, loads title/GAMIMAGE, palette fade. Calls 0x418600. Returns AX=0 on success |
0x418600 |
DirectDraw + archives + sound init. GRAFIX/world files load during this |
0x43AC40 |
MAIN GAME LOOP (vtable[7]). State machine; state byte at [this+0x18]; loops while 0x10 <= state < 0xf0. Per-frame calls vtable[8]=0x436170, vtable[17]=0x43adc0 (msg pump), vtable[9]=0x4361d0. Exits when state = 0xf0. No frame limiter here = FPS-coupled |
0x4791A0 |
Game object vtable (0x14-entry) |
0x42a480 |
WORLDS\ data-file loader (the copy-protection function, now patched at FO 0x29a2b) |
0x439380 |
Stream open method: CreateFileA([this+4], ...), vtable 0x479af8 |
0x444db0 |
GRAFIX0N.DAT loader (byte-swaps BE records; template for reading other WORLDS loaders) |
0x40C8E0 |
Busy-wait frame limiter (~60/sec via timeGetTime), used in palette-fade / state transitions (0x40CAE0), NOT in the gameplay loop |
0x40b5b0 |
Win32 WndProc dispatch (msg 2 WM_DESTROY -> 0x40b680 sets state=0xf0 + PostQuitMessage; focus msgs 7/0x1c handled) |
0x409FC0 |
DirectSound init |
0x42D970 |
show_error_dialog (patched to RET so failures do not popup) |
0x4824E4 |
timeGetTime IAT; [0x47e56c] holds a 60Hz tick baseline (set once, not used as a per-frame delta) |
Reproduce any disassembly with r2:
python3 -c "import r2pipe; r=r2pipe.open('HAVOC_NOCD.EXE',['-e','bin.relocs.apply=true']); print(r.cmd('pd 60 @ 0x43ac40'))"
6. File formats: status
Cracked (see docs/FORMATS.md for specs and tools/ for extractors):
.FF"FlashFile" archive (tools/ff_extract.py).- MUSIC.FF: 97 WAV segments + 17 PLST-sequenced songs + FL Studio pack.
- INTRFACE.FF: bitmap system (BitB/BitR headers, XPAL palettes, STR# string tables).
- GRAFIX0N.DAT (
tools/grafix_extract.py): BE header (size@0x08, count@0x0c), 16-byte entry records (BE offset + id), per-graphic blocks (BE w/h/frames/id + 8-bit indexed pixels). 64x64.
TODO (the asset-cracking stream):
- MAP.MAP: 2-byte-per-cell grid, ~192x144 confirmed. Next: disassemble its loader to get
exact dims + which byte is tile vs height. Find the loader by searching for the code that opens
"MAP" filenames (same pattern as GRAFIX via
0x42a480). - STUF.DAT: entity placement. Contains ASCII
"Item"records - very parseable. Start here for a quick win. - LAND.DAT: terrain geometry (hardest). BE fields; music-track id is a BE u16 at offset
0x12 (per prior notes). Has 4-byte records around 0x20 (
17 70 00 c0...). - OBJECTS.FF: 3D models (vehicles, enemies, pickups) - not yet touched.
- World palette: GRAFIX tiles currently render with a UI XPAL palette. The true per-world 3D
palette is TBD - likely loaded during
0x418600init or embedded per-world. Finding it makes all extracted art color-correct.
7. Ghidra decompilation stream (primary Linux task)
Load HAVOC.EXE (or HAVOC_NOCD.EXE) into Ghidra (x86 32-bit, image base 0x400000). Import the
key addresses from section 5 as labels to bootstrap. High-value targets, roughly in order:
- The FPS-coupling / render modes. The user reports cockpit mode is FPS-coupled but the
minimal HUD mode is not. Find the mode toggle and the two render/update paths. Determine
whether minimal mode uses a frame limiter (Sleep/timeGetTime) or a fixed-timestep/delta update.
That path is the template for the remaster's fixed-timestep, and possibly a cleaner in-binary
fix than the dgVoodoo FPS cap. Start from the main loop
0x43AC40and its per-frame methods0x436170/0x4361d0, and the busy-wait limiter0x40C8E0. - Cheat code handler. Codes
mmm(max heatseeker),vvv(free vehicle),sss(shield),hhh(health),aaa(ammo). Find the keyboard-buffer matcher; it will reveal the full cheat list and the player/inventory structs. - Weapon system. 6 primary classes (Laser, Disc, Shockwave, Fireball, Plasma, VARG) infinite
ammo; 14 secondary lines (see
docs/WEAPONS.md, from string tables STR#7D02/7D03). Recover the weapon data tables, upgrade tiers, and firing logic. - Player / vehicle physics (movement constants, the FPS-coupled step) and enemy AI.
- Level + entity loaders (MAP/LAND/STUF) - decompiling these also directly finishes the asset-cracking stream (get exact record layouts instead of guessing).
- World table: 6 worlds stored in reverse (Overlord, Wasteland, Malterra, Tyrak, Fallout,
Badlands), 3 levels + boss + bonus each. See
docs/FORMATS.mdand game-mechanics notes.
8. Methodology playbook (how the cracking was done)
- Crack a format via its loader, not guesswork. Find where the game opens the file
(filenames are in
.data; the loader0x42a480buildsWORLDS\<name>paths), then read the loader's parse code. GRAFIX was solved by reading0x444db0(the 16-byte record stride and BE->LE byte-swap gave the record layout exactly). - These WORLDS files are big-endian (Mac-origin data; the loaders byte-swap). Always try BE first for headers/records.
- Find grid dimensions by row autocorrelation (minimize mean abs diff between byte j and j+stride over candidate strides); then render grayscale at that width and eyeball for coherent structure. That is how MAP's 384-byte row (192x2) was found.
- Verify visually. Render extracted pixel data to PNG and build contact sheets; correct
decodes look like real art. Pillow
Image.frombytes('P', (w,h), data); putpalette(...). - r2pipe pattern for scanning: open with
bin.relocs.apply=true, iterate.textFOs looking for opcode patterns (e.g.ff 15CALL [IAT],a1/8b 05mem reads) and convert FO->VA.
9. Tool inventory (tools/)
ff_extract.py-.FFarchive extractor (archives, songs, FL pack).--helpfor flags.grafix_extract.py- GRAFIX0N.DAT -> PNG (-p <768-byte palette>).dbg_path.py- Windows-only ctypes debugger (used for the boot crash; keep for reference).- Many
r2*.py/find_*.py/analyze*.py- one-off RE probes from the crash hunt and format work. Useful as r2pipe examples; not all are load-bearing. run_probe.bat/run_and_log.bat- Windows launch helpers (repro/testing on Windows only).
Palettes: extract with python3 tools/ff_extract.py INTRFACE.FF -o tools/out_intrface, palettes
land in tools/out_intrface/raw/XPAL*.DAT (768-byte RGB).
10. Immediate next steps (suggested)
- (Asset, quick win) Crack
STUF<lvl>.DATentity records (ASCII"Item"markers). - (Asset) Disassemble the MAP loader to lock MAP dims + cell meaning; render a proper level map.
- (Ghidra) Stand up the project, label the section-5 addresses, and chase the render-mode / FPS-coupling path (item 7.1) - it informs both the exe fix and the Godot physics.
- (Remaster) Once STUF + MAP + a world palette are done, there is enough to scaffold the Godot project (empty today) and load a real level.
The FPS workaround (dgVoodoo FPSLimit = 30) is a stopgap; the user is tuning the value. A proper
fix is either a frame limiter patched into 0x43AC40 (the game imports Sleep) or, better,
adopting the minimal-mode timing path once Ghidra reveals it.