havoc-remaster/docs/HANDOFF.md
pyr0ball 763509e4bc feat: set up Linux Ghidra pipeline, find and patch FPS-coupling and pause-hang bugs
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.
2026-07-03 22:32:28 -07:00

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:

  1. Faithful 1:1 (original bugs preserved as an option, including FPS-coupled cockpit physics).
  2. Remaster (bug fixes, widescreen, rebindable input, higher-res).
  3. Roguelite (procedural worlds, extra tanks) as a separate mode.

Two active work streams for the next sessions:

  • A. Ghidra decompilation of HAVOC.EXE to recover game logic (weapons, AI, physics, cheats, the FPS-coupling, level/entity loaders).
  • B. Asset cracking of the remaining WORLDS/ and .FF formats.

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.EXE is the patched original (28 patches). The final blocker was a copy-protection check in the data-file loader 0x42a480 that only accepted files loaded from the CD drive; a 14-byte patch makes it accept the local files. Full write-up and patch table in docs/PATCHES.md.
  • Formats cracked: .FF archives, MUSIC.FF (17 songs + FL Studio pack), INTRFACE.FF bitmaps, and now GRAFIX0N.DAT (64x64 texture tilesets, tools/grafix_extract.py). See docs/FORMATS.md.
  • MAP layout partially decoded: MAP0101.MAP is 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, converts timeGetTime() 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 dgVoodoo FPSLimit=30 fixes 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 in docs/METHODOLOGY.md section 10; function-by-function status in docs/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 (personal pyr0ball account, not the CircuitForge org). Origin is already configured.
  • Pushing: the user pushes on request. Token lives in a local .env.mirror file (outside the repo) as FORGEJO_TOKEN - never print it, never store it in .git/config. Push via git -c http.extraheader="Authorization: token $TOKEN" push for 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 (not git add -A) and sanity-check git 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 0x418600 init 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:

  1. 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 0x43AC40 and its per-frame methods 0x436170 / 0x4361d0, and the busy-wait limiter 0x40C8E0.
  2. 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.
  3. 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.
  4. Player / vehicle physics (movement constants, the FPS-coupled step) and enemy AI.
  5. Level + entity loaders (MAP/LAND/STUF) - decompiling these also directly finishes the asset-cracking stream (get exact record layouts instead of guessing).
  6. World table: 6 worlds stored in reverse (Overlord, Wasteland, Malterra, Tyrak, Fallout, Badlands), 3 levels + boss + bonus each. See docs/FORMATS.md and 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 loader 0x42a480 builds WORLDS\<name> paths), then read the loader's parse code. GRAFIX was solved by reading 0x444db0 (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 .text FOs looking for opcode patterns (e.g. ff 15 CALL [IAT], a1/8b 05 mem reads) and convert FO->VA.

9. Tool inventory (tools/)

  • ff_extract.py - .FF archive extractor (archives, songs, FL pack). --help for 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)

  1. (Asset, quick win) Crack STUF<lvl>.DAT entity records (ASCII "Item" markers).
  2. (Asset) Disassemble the MAP loader to lock MAP dims + cell meaning; render a proper level map.
  3. (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.
  4. (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.