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.
This commit is contained in:
pyr0ball 2026-07-03 22:32:28 -07:00
parent 68cf36a3d0
commit 763509e4bc
7 changed files with 640 additions and 7 deletions

9
.gitignore vendored
View file

@ -24,14 +24,18 @@ tmp_cd/
# --- Third-party (dgVoodoo2 DirectDraw wrapper) - not ours to redistribute ---
dgVoodoo.conf
dgVoodoo*.conf
dgVoodooCpl.exe
ddraw.dll
DSETUP*.DLL
# --- Archived/experimental patched binaries (still original game code, copyright) ---
archive/
# --- Ghidra: install is third-party; project + decompile output embed the binary ---
ghidra/
tools/ghidra_*/
tools/ghidra_decompile.txt
tools/ghidra_decompile*.txt
# --- Derived audio / extracted assets (also copyrighted) ---
tools/out_*/
@ -65,3 +69,6 @@ tools/refs/
*.swp
.DS_Store
Thumbs.db
# --- Tool session cruft ---
.playwright-mcp/

144
docs/FUNCTIONS.md Normal file
View file

@ -0,0 +1,144 @@
# Havoc Function Tracker
Progress tracker for reverse-engineered functions in `HAVOC_NOCD.EXE`, kept in git so it's
diffable and gives an honest "how much of this binary do we actually understand" number for
the blog post. This is a curated list of functions worth naming/understanding, not a full
dump of all ~987 functions Ghidra's auto-analysis found.
**Status legend:**
- `unanalyzed` -- known to exist (an address we care about), not yet decompiled/read
- `analyzed` -- decompiled and read, behavior understood, still has a Ghidra default `FUN_*` name
- `named` -- renamed in the Ghidra project (`tools/ghidra_project/`) to reflect what it does
- `documented` -- named *and* has a writeup in one of the other `docs/*.md` files
Update this table whenever a function moves between these states. VA = virtual address
(runtime address with relocations applied; see `docs/HANDOFF.md` section 5 for FO->VA
formulas per section).
## Boot / init / main loop
| VA | Name | Status | Notes |
|---|---|---|---|
| `0x464657` | CRT entry point | documented | See `docs/HANDOFF.md` section 5 |
| `0x407C90` | WinMain | analyzed | Allocs game object, calls init (`0x434040`), then main loop (`0x43AC40`) |
| `0x434040` | main init | analyzed | Allocs ~30 subsystem objects, loads title/GAMIMAGE, palette fade, calls `0x418600` |
| `0x418600` | DirectDraw + archives + sound init | unanalyzed | GRAFIX/world files load during this |
| `0x43AC40` | `main_game_loop` (vtable[7]) | named | State machine, state byte at `[this+0x18]`. No frame limiter -> FPS-coupled. See `docs/METHODOLOGY.md` section 10 |
| `0x436170` | `per_frame_vtable8` | named | Calls `frame_tick_counter_while_playing` then conditionally two more vtable calls gated on state/flags |
| `0x4361d0` | `per_frame_vtable9_state_machine` | named | Sets the 60Hz tick baseline (`DAT_0047e56c`) on first call; handles state transitions 0x47/0x49/0xa1 |
| `0x43adc0` | `msg_pump` (vtable[17]) | named | Linked-list event queue drain, dispatches via `[this]+0x3c` |
| `0x40C8E0` | `busywait_frame_limiter_60hz` | named | ~60/sec via `timeGetTime`; used in palette-fade / state transitions, NOT the gameplay loop |
| `0x40CAE0` | `state_transition_with_limiter` | named | Palette-fade / state-transition context that calls the limiter |
| `0x418870` | `frame_tick_counter_while_playing` | named | Calls `dsound_stream_pump` then, if state==0x47, increments a counter at `this+0x158` |
## FPS-coupling investigation (docs/HANDOFF.md section 7.1)
| VA | Name | Status | Notes |
|---|---|---|---|
| `0x0047da88` (data) | game/window object ptr | analyzed | Same object as `this` in the main loop `0x43AC40` -- confirmed by reading its vtable at `0x4791A0` and matching slots 7/8/9/17 to the known main-loop callees. Assigned once in `FUN_00426560` (a window-init routine: `ShowWindow`/`UpdateWindow`/`SetWindowLongA`, offset `+0x943` = HWND) |
| offset `+0x92a` | **CORRECTED: common base "Window" class active/shown flag, not cockpit/HUD-specific** | analyzed | Scanned all instructions program-wide for this offset (`ScanForOffset.java`, since it's a struct offset not a fixed global address) -- found ~29 distinct functions across a huge address range (`0x415262` to `0x4624e0`) reading/writing it. This is a shared base-class member used by many menu/dialog/window objects throughout the UI system, not specific to the top-level game object or to cockpit/HUD mode. Retracts the earlier "possible cockpit/HUD-mode flag" guess |
| `0x426170` / `0x4264d0` / `0x426470` | base Window class `Init` / `Show` / `Hide` | analyzed | Found clustered together (same class impl). `0x426170`: constructor-like, zeroes a large field block, sets `+0x92a=0`. `0x4264d0` (**Show**): calls vtable[31] (`+0x7c`) then sets `+0x92a=1`. `0x426470` (**Hide**): sets `+0x92a=0`, calls `0x418a30`, conditional sub-object cleanup. This is the base class every menu/dialog screen likely inherits -- relevant to the keyboard-reassignment graphics bug investigation below |
| vtable slot `0x54` (21, `0x4188c0`) on the base vtable `0x4791A0` | base-class default stub, likely NOT a bug on its own | analyzed | Still just `MOV AX,1 / RET` on the *base* class. Given `+0x92a` is now known to be a common "active" flag checked by many classes, this being a no-op default for the plain base/top-level window is plausible and not necessarily wrong -- the real per-menu-screen override would live on each menu's own derived vtable, not this one. Not re-litigated as the FPS-coupling mechanism (still correctly ruled out for that), but also not confirmed as unrelated to the keyboard-menu bug -- would need to find the *keyboard-reassignment dialog's own object/vtable* specifically, not the top-level game object's |
| `0x418870` | `frame_tick_counter_while_playing` | named | Calls `dsound_stream_pump()` unconditionally, then if state==0x47 (playing) increments a counter at `this+0x158`. Not the physics update itself |
| `0x40a3f0` | `dsound_stream_pump` -- **dead end for physics** | named | References `"DSound Error: SoundBuf->Lock fail..."` / `"...BackgroundBuf->Lock..."` strings and `Lock`/`Unlock`-style vtable calls. This is double-buffered audio streaming, not physics |
| `0x40ecb0` | `player_turn_smoothing_substep` (called only while state==0x47) | named | Takes `DAT_0047c488` (see below) and runs a proportional-smoothing loop `DAT_0047c488` times on a value at `param_1+0xbd`/`+0x121` (steering/turn smoothing?). Confirms `DAT_0047c488` is consumed as a sub-step count, i.e. treated as elapsed ticks |
| `0x40e980` | `player_cooldown_timers_tick` (called only while state==0x47) | named | Subtracts `DAT_0047c488` from several countdown timers on the player/vehicle object (`param_1+0x1a1/0x1a5/0x1a9/0x1ad` -- cooldowns/animation timers), firing side effects (sound events via `DAT_0046ea30`/`DAT_0047c038` vtables) when they expire |
| **`0x4092d0`** | **`compute_frame_delta_ticks_60hz_min1_clamp` -- THE key find** | named | `DAT_0047c488 = FUN_004092d0()` is called once per frame from `0x40e980`. Converts `timeGetTime()` (ms) to a 60Hz tick count (`ms*60/1000`), diffs against the stored value from last call, returns the tick delta. **Min-1 clamp:** `if (iVar2 == 0) iVar2 = 1;` -- if the game is rendering faster than 60fps, less than one 60Hz tick elapses between calls (delta rounds to 0), but the function clamps the minimum return value to 1 tick anyway. Net effect: above 60fps, simulation time is credited faster than real time, in direct proportion to render rate -- a clean, **deterministic linear speedup**, not glitchy/non-deterministic breakage (confirmed by the user: "physics does not break above 60fps, it's deterministic and doesn't seem to behave any differently than 'FASTER'" -- exactly what this clamp predicts). At or below 60fps the delta is accurate (real elapsed ticks, never hits the clamp), which is exactly why the dgVoodoo `FPSLimit=30` workaround works. This is a real per-frame delta-time system with one specific min-clamp causing the speedup, not a naive "no delta timing at all" -- worth correcting the `docs/HANDOFF.md` characterization of "no frame limiter = FPS-coupled" to the more precise "has delta timing, but a >60fps min-tick clamp makes simulation speed scale with framerate above that threshold" |
| `0x0047c488` (data) | per-frame delta value (ticks) | analyzed | Recomputed by `0x4092d0` each call; consumed by `0x40ecb0` (sub-step loop count) and `0x40e980` (timer decrement amount) |
| `0x435610` (vtable slot 12) | unanalyzed | unanalyzed | Real function (not a stub) on the same vtable, unexplored |
| `0x437370` (vtable slot 15) | unanalyzed | unanalyzed | Real function (not a stub) on the same vtable, unexplored |
| **Open question, REVISED** | cockpit ("normal") vs. full-viewport ("maximized") speed difference | unanalyzed | User testing (2026-07-03) with `FPSLimit=0` + `[DirectX] ForceVerticalSync=true` (both views now genuinely vsync-locked to the same real 60fps): **"normal" now runs at exactly the correct speed; "maximized" runs at exactly 2x normal's speed** -- and the 2x ratio is unchanged from before the dgVoodoo tuning (only "normal"'s absolute speed was fixed by the config change). Since both views are confirmed at the *same actual framerate* now, the render-cost/"naturally throttled below 60fps" theory from `docs/PATCHES.md` **cannot explain a persisting 2x gap** -- framerate parity should mean parity in the `compute_frame_delta_ticks_60hz_min1_clamp` clamp behavior too, since neither view exceeds 60fps anymore. This points instead to a genuine **code-level difference**: something in the full-viewport/"maximized" path likely calls the per-frame update/tick machinery (`player_cooldown_timers_tick`/`player_turn_smoothing_substep`, or whatever drives them) an extra time per rendered frame, independent of actual framerate. Not yet located -- the `+0x92a` flag and its vtable-slot-21 gate (still just a base-class stub on the object checked so far) remain a lead, but given the flag is a generic base-class field used by ~29 unrelated functions, the real mode-switch code is more likely in whatever toggles between the cockpit-dashboard render path and the full-viewport render path (bound to the `+` key) -- not yet found. **Do not re-attempt the previous FPS patch approach (min-clamp removal) to explain this** -- that was a different, already-reverted-as-unsafe mechanism (see "FPS-coupling" section in `docs/PATCHES.md`) and doesn't explain a framerate-independent 2x multiplier anyway |
## Alt-tab / focus-loss investigation
Triggered by a real symptom report: menu appears to "reset" when alt-tabbing out and back in
while running via dgVoodoo2. Also connects to a second observation: the game renders as a
borderless-but-resizable window, not true OS fullscreen or a titled window.
| VA | Name | Status | Notes |
|---|---|---|---|
| `0x40b5b0` | `wndproc_dispatch` | named | Main WndProc. Handles `WM_DESTROY` (2, sets state=0xf0), `WM_ACTIVATEAPP` (0x1c), `WM_SETFOCUS`-family (7/0xf), plus DirectDraw palette/mouse messages (0x201/0x202/0x203) and custom app messages 0x401-0x405 |
| `0x436070` | `on_deactivate_force_pause_state` (vtable slot 13 / `0x34`) | named | On `WM_ACTIVATEAPP(0)`: skips if state==0xf0 (exiting). Otherwise backs up current state to `DAT_0047e578`, force-sets state to `0xB0` (pause), calls `0x40a8b0`, a vtable[23] call on a secondary object, then `state_transition_with_limiter` |
| `0x4360e0` | `on_reactivate_restore_state` (vtable slot 14 / `0x38`) | named | On `WM_ACTIVATEAPP(1)`: **correctly restores** the state backed up in `DAT_0047e578`. Calls `ddraw_full_display_reinit`, then vtable[30]/[34] calls on a secondary object, conditionally `0x40a910`, then `dsound_stream_pump`, then `state_transition_with_limiter`. The state machine itself is NOT the bug -- it round-trips cleanly |
| `0x40bd30` | `ddraw_full_display_reinit` | named | **The actual heavy-lifter.** Full DirectDraw teardown/rebuild: `SetDisplayMode`, `CreateSurface` (primary+back, with retry loop on `DDERR_SURFACELOST`/`DDERR_WASSTILLDRAWING`), `Lock`, `CreatePalette`. This is the exclusive-fullscreen "recreate everything after losing the display device" path, not a lightweight `IDirectDrawSurface::Restore()`. Explains both symptoms: (1) the visible "reset" flash on alt-tab-back-in is a real full surface recreation, and (2) it only makes sense if the game requested `DDSCL_EXCLUSIVE\|DDSCL_FULLSCREEN` -- which is consistent with dgVoodoo2 presenting it as a borderless-but-resizable window rather than a normal titled window (dgVoodoo's own rendering of a wrapped exclusive-fullscreen DirectDraw app) |
| **Conclusion, PARTIALLY REVISED (two distinct symptoms, not one)** | state machine confirmed not buggy; two separate "reset" triggers exist | -- | Internal game state survives alt-tab correctly at the state-machine level (confirmed originally, still true). Two *different* real-world symptoms both got called "resets to menu" during testing, and they have different causes: **(1) alt-tab while paused, staying away/paused too long** -- this was the pause-loop message-queue hang (see `docs/PATCHES.md` "Pause-loop hang"); confirmed fixed by `HAVOC_NOCD_PAUSEFIX_v1.EXE` (2026-07-03 test: alt-tab away while paused, return, unpause -> continues normally). **(2) alt-tab while NOT paused** -- user confirmed (2026-07-03, same session, same `HAVOC_NOCD_PAUSEFIX_v1.EXE` binary) this STILL resets to the menu on refocus, so it is a genuinely separate, still-unresolved issue -- the pause-loop fix did not touch it. Since the WM_ACTIVATEAPP handlers (`on_deactivate_force_pause_state`/`on_reactivate_restore_state`) run unconditionally on every alt-tab regardless of pause state, and we already confirmed those two functions round-trip the state variable correctly, the cause of symptom (2) is still open -- likely either in `ddraw_full_display_reinit`'s surface recreation actually failing to restore the correct visual content (not just the state int), or some other object beyond the one game-state variable we've traced so far. **Do not assume this is resolved** -- re-open investigation if picked back up |
| **Tested** | mouse input in menu, fresh launch vs. post-alt-tab | -- | User confirmed (2026-07-03): mouse responds correctly in the menu both on fresh launch and after alt-tabbing. An earlier report of "menu doesn't respond to mouse" was transient/not reproducible -- not a real regression from the `ddraw.dll` permission fix, the `FPSLimit`/`ForceVerticalSync` tuning, or this investigation |
## Keyboard-reassignment menu graphics bug (first genuine bug found this session)
Symptom: on the "Change Keyboard Settings" screen, the dynamic elements (the list of game
functions + their currently-assigned keys, since the static menu chrome loads fine) flash
for a fraction of a second on menu load, then disappear and never redraw.
Confirmed the string table for this screen: `INTRFACE.FF` resource `STR#7A62`
(`tools/out_intrface/raw/STR#7A62.dat`): `"Press a key to assign to selected game function." /
"Cancel" / "Save Settings" / "Restore Default Settings"`. The parent Options menu is
`STR#7A44`: `"Change Game Settings" / "Change Viewscreen Settings" / "Change Keyboard
Settings" / "Change Joystick Settings" / "Return to Main Screen"`.
**Initial hypothesis (retracted, see the `+0x92a` correction above):** thought the
per-frame vtable[21] stub on the top-level game object was the missing dynamic-redraw call.
Scanning showed `+0x92a` is a common base "Window" class active-flag used by ~29 different
functions across the codebase -- not specific to this menu. The stub at `0x4188c0` is the
*base class's* default and is plausibly correct/intentional there; it doesn't mean every
derived window's slot 21 is a stub.
**Progress: found the Options-menu construction function as a template.** `0x4596c0`
(scanned via the `0x7a44` resource-ID search) builds the whole "Change Game/Viewscreen/
Keyboard/Joystick Settings" screen: allocates a button widget per string ID starting at
`0x7a45` (looping, each with its own vtable -- `PTR_LAB_004795b0`, `PTR_LAB_004794e0`,
`PTR_FUN_00479610`, `PTR_LAB_00479448`, `PTR_LAB_00479478`, `PTR_FUN_00479070` are all
distinct widget-subclass vtables: buttons, labels, sliders), sets a title via `0x426240`
(likely `SetTitleString(resourceId)`), then finishes with `FUN_00426240(param_1, 0x7a44)`
followed by `FUN_004264d0(param_1)` -- **that's the base-class `Show` method we identified**,
called on the newly-built screen object itself. Confirms the general pattern: every menu
screen is its own object instance with its own per-widget vtables, constructed by a
dedicated "build this screen" function, finished off by the common `Show`.
Also found `0x458350`, a widget message handler that references `0x7a62` (computing a
string-ID offset based on a selection index for what looks like a status/tooltip line, matching
STR#7A44's `"Move the mouse over a button for a description of its function."`) -- likely a
*shared* button/widget message handler reused across multiple screens, not specific to the
keyboard-reassignment screen's own construction.
**Still open, next step:** find the keyboard-reassignment screen's own construction function
-- the equivalent of `0x4596c0` but building buttons/labels from string IDs starting around
`0x7a63` (the item after `STR#7A62`'s header string) instead of `0x7a45`. Once found, check
whether its per-widget-type vtables (for whatever draws the dynamic key-binding list
specifically) have a real per-frame update/redraw implementation or a stub -- that's where
the actual bug should surface. Given "flash once then disappear" (not "never drawn at all"),
the initial static draw works -- something about the *per-frame* redraw path for this
screen's dynamic list specifically is what's missing or broken.
## Copy-protection / file loading (docs/PATCHES.md)
| VA | Name | Status | Notes |
|---|---|---|---|
| `0x0042a480` | WORLDS\ data-file loader | documented | The copy-protection check function; patched. See `docs/PATCHES.md` |
| `0x0042a880` | fn_init_file_objects | analyzed | |
| `0x0042db00` | delete_savegame | analyzed | `DeleteFileA` wrapper |
| `0x0042dab0` | strcpy_to_obj | analyzed | |
| `0x0042d920` | error_display_6 | analyzed | |
| `0x0042d970` | show_error_dialog | documented | Patched to `RET` (Patch N3) to silence popups |
| `0x00438d80` | get_strdata_string | analyzed | |
| `0x004274e0` | alloc_obj | analyzed | |
| `0x00444db0` | GRAFIX0N.DAT loader | documented | Byte-swaps BE records; template for other WORLDS loaders. See `docs/FORMATS.md` |
## Not yet started (from docs/HANDOFF.md section 7)
| Target | Status | Notes |
|---|---|---|
| Cheat code handler (`mmm`/`vvv`/`sss`/`hhh`/`aaa`) | unanalyzed | Find the keyboard-buffer matcher |
| Weapon system (6 primary + 14 secondary) | unanalyzed | See `docs/WEAPONS.md` for the string-table side |
| Player/vehicle physics | unanalyzed | |
| Enemy AI | unanalyzed | |
| MAP/LAND/STUF loaders | unanalyzed | Also finishes the asset-cracking stream |
## How to update this
1. Decompile with the headless scripts in `tools/ghidra_scripts/` (e.g. `DecompileFPS.java`,
`DecompileHavoc.java` -- extend their `TARGET_VAS` arrays, or add a new script for a new
investigation topic).
2. Read the output in `tools/ghidra_decompile*.txt`, update the row's status here.
3. If you rename a function in the Ghidra GUI/project, bump status to `named` here too.
4. If it gets a real writeup elsewhere in `docs/`, bump to `documented` and link it.

View file

@ -45,11 +45,17 @@ write down the *discovery steps and dead ends*, not just the final spec. Keep no
`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.
- **Known open gameplay issue:** movement/physics is **FPS-coupled** (one sim step per rendered
frame, no fixed timestep). Workaround in place: dgVoodoo `FPSLimit` set to 30 (tunable). The
user's memory says the in-game "minimal" HUD mode was NOT FPS-coupled while "cockpit" mode was,
which suggests a limiter/delta path already exists in the binary for one mode. Finding that in
Ghidra is a priority (see section 7).
- **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`.
---
@ -65,7 +71,7 @@ Tooling used this session and how it maps to Linux:
| 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 | (not yet used) | **Ghidra** (Java, cross-platform) - the main Linux task |
| 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
@ -79,6 +85,36 @@ All the extraction tools (`ff_extract.py`, `grafix_extract.py`) are cross-platfo
**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):**
```sh
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

40
docs/LEVEL_NOTES.md Normal file
View file

@ -0,0 +1,40 @@
# Level / AI Design Notes
Running list of level-specific design quirks and idiosyncrasies noticed during play-testing,
separate from `docs/PATCHES.md` (binary bugs/fixes) and `docs/FUNCTIONS.md` (RE progress).
These are candidates for the **Remaster** variant's "bug fixes" bucket (see `docs/HANDOFF.md`
section 1) while staying intact in the **Faithful 1:1** variant. Nothing here has been
reverse-engineered yet -- these are gameplay observations to revisit once the AI/level-loader
code (MAP/STUF/LAND, enemy AI) gets decompiled.
## World 1 (Badlands), first level
**Unreachable stranded drop from a flying enemy.** One of the final flying enemies in this
level has a patrol/movement pattern that can carry it over a tall piece of terrain. If it
isn't killed early -- before most other enemies in that area -- it can end up dying while
positioned over that tall terrain, and its item drop can land somewhere physically
unreachable (stuck on/above terrain the player can't get to).
- Not a crash or technical bug -- a level/AI design issue remembered from the original game,
confirmed again during a 2026-07-03 playthrough.
- Workaround (as originally learned): kill this specific enemy early, before it can drift
into the problem area.
- **Remaster candidate:** once `STUF<lvl>.DAT` (entity placement) and the AI/pathing code are
decompiled, this could be fixed by adjusting the enemy's patrol bounds, or by making drops
from airborne kills fall to the nearest reachable ground instead of wherever the enemy died.
- Not yet reverse-engineered -- no addresses, no code path identified. Revisit once AI/level
loading is a priority (see `docs/HANDOFF.md` section 7, items 4-5).
## Template for new entries
```
## World <N> (<name>), level <N>
**Short description.** Longer explanation of the quirk, when it happens, how to reproduce
or avoid it.
- Bug or design quirk?
- Reproduction notes / workaround if known.
- Remaster candidate? What would the fix look like.
- RE status: not yet investigated / found at VA 0x... / etc.
```

View file

@ -243,3 +243,242 @@ The ice/snow biome is named "Fallout" in the EXE despite its visual aesthetic.
- `OBJECTS.FF`: 3D models -- the hard one; `ASND*` entries TBD
- `BIGFILE.DAT`: 64 MB bulk data pool (starts with copyright string then RIFF data)
- Ghidra decompilation of `HAVOC.EXE` for game logic, AI, cheat codes, weapon system
## 9. Ghidra environment setup (Linux, headless, no sudo)
Standing up Ghidra on the Linux dev box (`Heimdall`, headless -- no `$DISPLAY`, no
passwordless `sudo`) this session. Recorded because the obvious paths didn't work.
**Dead end #1: the existing `ghidra/` checkout.** The repo already had a `ghidra/`
directory (already gitignored) containing a full clone of the NSA `ghidra` *source* repo --
not a build, not a submodule of this repo, just a bare clone. It was owned `nobody:nogroup`
with no write bit for the working user, so it couldn't be reused, modified, or removed
without root. Building Ghidra from source also requires the full Gradle + JDK toolchain,
which is unnecessary work when prebuilt releases exist. Left in place untouched; a prebuilt
install was set up alongside it instead.
**Dead end #2: `nobody:nogroup` ownership was repo-wide, not just `ghidra/`.** Most of the
repo (docs, tools, the game files themselves) came up owned `nobody:nogroup` too, blocking
writes. Cause: this repo lives on NFS-mounted storage (`/Library`, mounted from `Navi`), and
files saved onto it from the Windows box in earlier sessions landed with squashed
`nobody:nogroup` ownership rather than mapping to the Linux user. Fixed with
`sudo chown -R alan:alan` from a session with the actual sudo password (this session didn't
have one) -- not a code/tooling issue, just an NFS cross-platform ownership quirk to expect
whenever files get written from the Windows side and then edited from Linux.
**Working approach: prebuilt release + local JDK, entirely in `$HOME`, zero sudo.**
1. Ghidra ships prebuilt zips on GitHub releases -- no build step needed:
```sh
curl -s https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest \
| grep browser_download_url
```
Got `Ghidra 12.1.2` (`ghidra_12.1.2_PUBLIC_20260605.zip`).
2. Ghidra 12.x requires JDK 21. No system Java was installed and `apt` needs sudo, so a
Temurin JDK 21 tarball was pulled directly from Adoptium's API (no root needed --
it's just an archive extracted to `$HOME`):
```sh
curl -sL -o jdk21.tar.gz \
"https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk"
```
3. Both extracted under `~/tools/` (outside the repo entirely -- this is a personal
toolchain install, not project data):
```
~/tools/ghidra_12.1.2_PUBLIC/
~/tools/jdk-21.0.11+10/
```
4. Verified with the headless analyzer (no GUI needed for decompilation work on a headless
box -- `analyzeHeadless` covers import + auto-analysis + scripting):
```sh
export JAVA_HOME=~/tools/jdk-21.0.11+10
export PATH=$JAVA_HOME/bin:$PATH
~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless -help
```
5. Sanity check: imported `HAVOC_NOCD.EXE` into a scratch headless project and ran
auto-analysis, just to confirm the toolchain itself worked before touching anything
real:
```sh
~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \
~/ghidra-projects HavocRemaster \
-import /Library/Development/devl/Havoc/HAVOC_NOCD.EXE \
-analysisTimeoutPerFile 600
```
Succeeded in 43 seconds (Decompiler Parameter ID, Switch Analysis, and the usual PE
analyzers all ran clean; the only notable log line was `MinGW pseudo-relocation list not
found`, harmless -- the binary just isn't a MinGW build). This scratch project was
deleted once step 6 below made it redundant.
6. **Found the real project already existed.** `tools/ghidra_project/` turned out to hold
a genuine, previously-analyzed Ghidra project from an earlier Windows/GUI session --
`HAVOC.gpr` + an 8.4 MB `.rep` database, same Ghidra version (12.1.2) as just installed
here. There's also a working custom headless script, `tools/ghidra_scripts/
DecompileHavoc.java`, which decompiles a curated list of known-interesting VAs (the
copy-protection / file-loader functions from section 5) to `tools/ghidra_decompile.txt`.
Both are gitignored (`tools/ghidra_*/`, `tools/ghidra_decompile.txt`) so they never
showed up as tracked files, only as directories on disk -- easy to miss.
Opening it directly failed at first:
```
ghidra.util.NotOwnerException: Project is owned by pyr0ball
```
Ghidra stores the creating user's name in `HAVOC.rep/project.prp` and headless mode
refuses to touch a project owned by a different user (GUI mode would prompt; headless
just aborts). Fix was a one-line edit of that XML file, `OWNER` from `pyr0ball` to the
Linux username (`alan`) -- purely project metadata, doesn't touch the analysis database:
```sh
sed -i 's/VALUE="pyr0ball"/VALUE="alan"/' tools/ghidra_project/HAVOC.rep/project.prp
```
After that, both opening the project and running `DecompileHavoc.java` against it
worked headlessly:
```sh
~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \
tools/ghidra_project HAVOC -process HAVOC_NOCD.EXE -noanalysis \
-scriptPath tools/ghidra_scripts -postScript DecompileHavoc.java
```
**Found and closed: the project's binary was not byte-identical to the current
`HAVOC_NOCD.EXE` on disk.** The project file was *named* `HAVOC_NOCD.EXE` and was
imported from `//10.1.10.10/Library/Development/devl/Havoc/HAVOC_NOCD.EXE` (per Ghidra's
recorded `EXECUTABLE_PATH`), but its recorded `EXECUTABLE_MD5` (`d7a12ab7...`) matched
none of the three `HAVOC*.EXE` files then on disk (`HAVOC.EXE`, `HAVOC_NOCD.EXE`,
`HAVOC_NOCD.bak.EXE` -- all different MD5s from each other too). It was an earlier
iteration of the NOCD patch, superseded since the project was last saved on Windows.
Confirmed zero manual annotation existed to lose (`ListCustomNames.java`, a one-off
script in `tools/ghidra_scripts/`, reported `USER_NAMED_FUNCTIONS: 0` of 987 -- pure
auto-analysis, no renamed functions or comments), so the fix was a clean re-import rather
than a careful merge:
```sh
~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \
tools/ghidra_project HAVOC \
-import /Library/Development/devl/Havoc/HAVOC_NOCD.EXE \
-overwrite -analysisTimeoutPerFile 600
```
`-overwrite` replaces the existing same-named program with a fresh import + full
auto-analysis (39s). Re-running `PrintProgramMD5.java` afterward confirmed
`EXECUTABLE_MD5: e68fb854876232989951c2d813eaaff0`, matching `md5sum HAVOC_NOCD.EXE`
exactly -- the project is now analyzing the current, verified-working NOCD build, and
`tools/ghidra_decompile.txt` was regenerated against it.
**Takeaway for future sessions:** don't start a fresh Ghidra project. `tools/ghidra_project/`
is the canonical one (its `.gpr`/`.rep` are gitignored, so `git status` won't show it --
check the filesystem directly). It now matches the current `HAVOC_NOCD.EXE` (verified by
MD5, see above). If `HAVOC_NOCD.EXE` gets repatched again, re-run the `-overwrite` import
above and re-check `PrintProgramMD5.java` before trusting decompiled output. If a
*different* machine/user ever needs to open the project headlessly, expect a
`NotOwnerException` and fix it the same way (edit `OWNER` in `HAVOC.rep/project.prp`).
**Why headless instead of the GUI:** `Heimdall` has no X server. Ghidra's GUI needs a
display (X11 forwarding, VNC, or a local machine); the headless analyzer does not.
`analyzeHeadless` covers import, auto-analysis, and running Jython/Java post-scripts against
an existing project, which is enough to continue the decompilation stream started on
Windows. Interactive exploration of specific functions (the actual "click through the
decompiler" workflow) will need either a GUI session on a machine with a display, or a
remote-GUI approach (VNC/X11 forwarding) set up in a later session if headless scripting
proves too limiting.
## 10. FPS-coupling investigation
The user's report: movement/physics runs *faster* at high framerates -- deterministically,
not glitchy, just "faster" -- and capping the framerate (dgVoodoo `FPSLimit=30`) fixes it.
`docs/HANDOFF.md` section 7.1 asked whether this was a naive "one sim step per rendered
frame, no timestep at all" bug, and specifically whether a "minimal HUD" render mode used a
different, uncoupled timing path than "cockpit" mode. This section is the actual chase,
worked entirely with the headless Ghidra scripts in `tools/ghidra_scripts/` (one-off scripts
per step, since each step depended on what the previous one found).
**Step 1: confirm the main loop has no limiter.** Decompiled the known main-loop address
(`0x43AC40`, now named `main_game_loop`) and its per-frame callees from `docs/HANDOFF.md`
section 5 (`0x436170`, `0x4361D0`, `0x43ADC0`, plus the known limiter `0x40C8E0`/`0x40CAE0`
for comparison). Confirmed: the loop body calls its per-frame vtable methods with no
`Sleep`/`timeGetTime` gate in between -- matches the existing note that the busy-wait
limiter is used elsewhere (palette fades, state transitions) but not here.
**Dead end #1: a promising-looking gated per-frame call turned out to be a no-op stub.**
The loop conditionally calls `(**(code**)(*DAT_0047da88 + 0x54))(frame_count)` only when a
byte flag at `DAT_0047da88+0x92a` is set. This looked exactly like a mode toggle (cockpit vs.
minimal HUD gating an extra per-frame call). Cross-referencing `DAT_0047da88` (via Ghidra's
`ReferenceManager`, filtering to `WRITE`-type refs) found it's assigned once, in a
window-init routine (`ShowWindow`/`UpdateWindow`/`SetWindowLongA` -- clearly the main game
window object), and its vtable read back as `0x4791A0` -- the same vtable table documented
for the main-loop `this` object, confirmed by cross-checking known slots (7/8/9/17 all
matched). So `DAT_0047da88` is the same object/class as the main loop's `this`. Resolved
vtable slot 21 (offset `0x54`) to `0x4188C0` -- but Ghidra hadn't disassembled it as a
function at all (no code there yet). Forced disassembly (`createFunction`) revealed it's
just `MOV AX,1 / RET`. A trivial default stub, not physics. Whether the `+0x92a` flag is
actually the cockpit/HUD toggle is still unconfirmed either way -- the call it gates just
doesn't do anything interesting in this build, so this path was abandoned.
**Dead end #2: the other unconditional per-frame call is the audio streamer.** Vtable slot 8
(`0x436170`, `per_frame_vtable8`) unconditionally calls `0x418870` every frame. Decompiling
that led to `0x40A3F0`, which turned out to be double-buffered DirectSound streaming (string
references to `"DSound Error: SoundBuf->Lock fail..."` etc., `Lock`/`Unlock`-shaped vtable
calls). Not physics, just the music/audio buffer refill pump. `0x418870` itself just
increments a frame counter at `this+0x158` while state==0x47 ("playing").
**The actual find: `per_frame_vtable8` also calls two player-object functions only while
playing** (`0x40ECB0` and `0x40E980`, called from within `if (*(int*)(param_1+0x18)==0x47)`
in the earlier decompile). Both take the player/vehicle object pointer (`DAT_0047e42c`) and
both consume a shared global, `DAT_0047c488`, as an elapsed-ticks value:
`0x40ECB0` (`player_turn_smoothing_substep`) runs a proportional-smoothing loop
`DAT_0047c488` times; `0x40E980` (`player_cooldown_timers_tick`) subtracts `DAT_0047c488`
from several countdown timers on the player object. Both treating a shared global as "ticks
elapsed since last frame" is the signature of a real delta-time value -- worth finding what
computes it.
**`DAT_0047c488` is computed by `0x4092D0`, now named `compute_frame_delta_ticks_60hz_min1_clamp`:**
```c
int compute_frame_delta_ticks_60hz_min1_clamp(void) {
iVar2 = DAT_0047c48c; // ticks stored from the previous call
if (DAT_0047c48c == 0) { // first call: establish baseline
DAT_0047c48c = timeGetTime() * 60 / 1000;
iVar2 = 0;
} else {
iVar2 = timeGetTime() * 60 / 1000 - iVar2; // delta ticks since last call
DAT_0047c48c = DAT_0047c48c + iVar2;
}
if (iVar2 == 0) { iVar2 = 1; } // <-- the clamp
return iVar2;
}
```
This *is* real delta-time: `timeGetTime()` (wall-clock ms) is converted to a 60Hz tick count
and diffed against the previous call, same pattern as the known 60Hz baseline at
`DAT_0047e56c`. At or below 60fps, calls are spaced >= 1 tick apart in real time, so the
delta is accurate and the clamp never triggers. **Above 60fps, less than one 60Hz tick
elapses between calls -- the true delta rounds to 0 -- but the function clamps the minimum
return value to 1 tick anyway.** Net effect: above the 60fps threshold, the game credits at
least 1 tick of simulated time per call regardless of how little real time actually passed,
so simulation speed scales up in direct, deterministic proportion to render rate. That
matches the user's own description exactly ("physics does not break above 60fps, it's
deterministic and doesn't seem to behave any differently than 'FASTER'") -- this is a clean
linear speedup from a min-clamp, not jitter or desync from a missing timestep. It also
explains why `FPSLimit=30` fixes it: staying under 60fps never reaches the clamp.
This corrects the framing in `docs/HANDOFF.md` section 2/7.1 ("no frame limiter = FPS-coupled",
implying no delta timing at all) to the more precise mechanism: **there is real delta-time
timing, gated by a specific >60fps minimum-tick clamp** in `compute_frame_delta_ticks_60hz_min1_clamp`.
**Open question, not yet resolved:** whether this clamp behaves differently between cockpit
and minimal-HUD modes. `player_turn_smoothing_substep` and `player_cooldown_timers_tick`
both gate only on `state==0x47` (playing), not on any HUD-mode flag found so far -- so on
current evidence this speedup should affect both modes equally, which doesn't yet explain
the user's original memory that minimal-HUD mode wasn't FPS-coupled. Either that memory was
about a different symptom (e.g. cockpit-only visual sway/bob integration, separately naive),
or the real position/rotation integration -- not yet located, distinct from the turn-smoothing
and cooldown-timer functions found here -- is the mode-dependent piece. Next step if this is
picked back up: find what actually writes the player object's position/orientation each
frame and check whether it takes a mode-dependent path. See `docs/FUNCTIONS.md` for the full
function-by-function status.
**Methodology note:** every function in this chase was found by decompiling one function,
reading what it called next, and following that thread -- exactly the "read the loader"
approach from section 8, just applied to the render loop instead of a file loader. The two
dead ends (the vtable stub, the audio pump) were only recognizable as dead ends *after*
decompiling them; there was no way to rule them out from the disassembly summary alone. All
functions found here were renamed in the live Ghidra project (`tools/ghidra_project/`) via
one-off `RenameOne.java`/`RenameFPSFunctions.java` scripts, so a GUI session opening the same
project later inherits these names for free.

View file

@ -47,6 +47,9 @@ code cave at VA 0x467bce is mapped into memory.
| 0x6b390 | 0x47df90 | `C:\WORLDS\LAND0101.DAT`→`WORLDS\...` | Strip drive prefix |
| 0x6b3a8 | 0x47dfa8 | `C:\HAVOCDAT.DAT`→`HAVOCDAT.DAT` | Strip drive prefix |
| 0x6b3b8 | 0x47dfb8 | `C:\BIGFILE.DAT`→`BIGFILE.DAT` | Strip drive prefix |
| ~~0x0871d~~ | ~~0x40931d~~ | ~~`85 c0 75 05 b8 01 00 00 00` → 9×`90` (NOP)~~ | **CONFIRMED UNSAFE, reverted** -- crashes on level load (`archive/HAVOC_NOCD_FPSFIX_v1.EXE` only, never merged into `HAVOC_NOCD.EXE`). See below for why and the corrected approach |
| 0x1fbcf | 0x4207cf | `0f 84 c9 fe ff ff``0f 84 0a 74 04 00` (retarget existing `JZ`) | **Pause-loop hang fix** (`HAVOC_NOCD_PAUSEFIX_v1.EXE`, statically verified, not yet play-tested): redirects through a new cave that pumps one Windows message per pause-loop iteration. See below |
| 0x66fdf | 0x467bdf | 12 new bytes in previously-unused padding (was all-zero) | Cave for the pause-loop fix above -- calls the existing message-drain function (vtable slot 18) then returns to the original loop target |
## The copy-protection / GRAFIX crash (the "black window then closes" bug)
@ -76,6 +79,142 @@ the caller `0x444db0` (GRAFIX loader) dereferences that NULL without checking
After this patch the game runs (window title "HAVOC(tm) by Reality Bytes", responsive, main
loop executing) instead of crashing.
## FPS-coupling: the min-1-tick clamp (experimental, pending test)
**Symptom:** movement/physics speeds up (deterministically, not glitchy) at high framerates.
Full discovery narrative in `docs/METHODOLOGY.md` section 10; function-by-function status in
`docs/FUNCTIONS.md`.
**Root cause:** `compute_frame_delta_ticks_60hz_min1_clamp` (VA `0x4092D0`) computes a
real per-frame delta from `timeGetTime()`, converted to 60Hz ticks, correctly accumulating
against the previous call. But its last three instructions clamp the *minimum* return value
to 1 tick even when the true elapsed delta is 0:
```
0x40931d: test eax,eax
0x40931f: jnz 0x409326 ; skip clamp if a real tick already elapsed
0x409321: mov eax,1 ; <- the bug: force at least 1 tick regardless
0x409326: pop esi ; (fallthrough target either way)
```
Above 60fps, real elapsed time between calls is under one 60Hz tick, so the true delta is 0
-- but the clamp credits a full tick of simulated time anyway. Net effect: simulation speed
scales up in direct, deterministic proportion to render rate once you exceed 60fps. This also
explains why the original "cockpit vs. minimal-HUD" symptom report was real: Havoc is a
software rasterizer, and the default cockpit view (small letterboxed 3D viewport) is cheap
enough to render well past 60fps on modern hardware (triggering the clamp), while the
full-viewport "zoomed out" view (toggle key `+`) is expensive enough to naturally stay under
60fps from raw rasterization cost alone (never reaching the clamp) -- no separate mode-specific
limiter code exists; it was always this one clamp, just masked by viewport-dependent render
cost.
**First attempt (FO 0x0871d, NOP the 9-byte clamp) -- CONFIRMED UNSAFE, do not use.**
Applied to `HAVOC_NOCD_FPSFIX.EXE` and play-tested: crashes on level load. Root cause of the
crash: before patching, only 2 of the delta value's consumers were checked
(`player_turn_smoothing_substep`, `player_cooldown_timers_tick`) and both looked safe against
a genuine 0. That check was **insufficient** -- a full reference scan afterward found
`DAT_0047c488` (the accumulated delta) is read in **203 places across 40+ distinct
functions** throughout the engine (physics, animation, weapon timers, AI -- effectively every
subsystem that needs "elapsed ticks this frame"). Almost certainly one of the ~38 unaudited
functions divides by this value, and a fresh level load -- where a wave of new objects
(enemies, projectiles, entities) run this code for the first time -- is exactly when a
never-before-hit zero-division path would surface. Lesson: `FindCallers`-style reference
scans need to be run *before* patching a widely-shared global, not just spot-checked on the
call sites that happened to be visible from the one code path already being traced.
**Corrected approach (not yet implemented):** don't allow the return value to ever be a
genuine 0 (too many unaudited consumers depend on that invariant). Instead, make
`compute_frame_delta_ticks_60hz_min1_clamp` **block until a real tick has elapsed** --
replace the `mov eax,1` lie with a busy-wait spin on `timeGetTime()` (the same polling
pattern the game's own `busywait_frame_limiter_60hz` already uses elsewhere in this binary),
re-reading and recomputing the real delta until it's nonzero. This preserves "return value is
never 0" for all 203 read sites while still fixing the actual bug: the function then
naturally self-throttles to at most 60 calls/sec instead of crediting phantom simulated time
above 60fps. Needs a small code cave (more involved than a plain NOP -- similar in spirit to
the existing "Patch V" cave technique above), not yet built or tested.
**Status:** reverted. Use unpatched `HAVOC_NOCD.EXE` for actual play. `archive/HAVOC_NOCD_FPSFIX_v1.EXE`
is kept on disk as a known-bad reference but should not be used. Until the corrected patch
exists, the dgVoodoo `FPSLimit=0` + `[DirectX] ForceVerticalSync=true` config (confirmed clean
during this session) is the safe way to avoid the >60fps speedup without touching the binary.
**Update:** further testing with that config revealed the "maximized" full-viewport view runs
at exactly 2x the speed of the "normal" cockpit view, even though both are now confirmed at
the same actual ~60fps (vsync-locked) -- meaning this specific min-clamp bug can't be the
whole story for that particular symptom (framerate parity should mean parity in the clamp's
behavior too). See `docs/FUNCTIONS.md` "cockpit vs. full-viewport speed difference" for the
live investigation into that separate 2x multiplier.
## Pause-loop hang (Windows "Not Responding" after a long pause)
**Symptom:** pause with TAB, optionally enter a cheat code, leave it paused for more than a
few seconds -- window greys out (Windows' "Not Responding" ghosting) and TAB no longer
unpauses, permanently.
**Root cause:** found while tracing the cheat-code handler (a HANDOFF-priority target,
found as a side effect of investigating this hang). `FUN_0041fb60`'s pause-command case
(`0xea02`) contains a **blocking busy-wait loop** that polls `GetAsyncKeyState` for TAB and
the cheat letters (A/S/V/M) directly, in a tight loop, and does not return to the caller (so
the Windows message queue never gets pumped) until TAB is pressed again:
```c
do { cVar1 = GetAsyncKeyState(VK_TAB); } while (cVar1 != '\0'); // wait for release
cVar1 = GetAsyncKeyState(VK_TAB);
while (cVar1 == '\0') { // spins here for the whole pause duration
// poll A/S/V/M, count toggle-transitions, fire cheats at 6 (=3 presses)
cVar1 = GetAsyncKeyState(VK_TAB);
}
```
This is period-correct 1995 code (no compositor to upset back then) meeting a modern Windows
behavior it was never tested against: if a window doesn't process its message queue within
Windows' hang-detection timeout (~5s), DWM detaches the real window and shows a static
"ghost" in its place. That ghost never receives real input again, even though the app's
thread is still alive and correctly polling keys underneath -- confirmed by testing: quick
pause/unpause cycles work fine (cheats included), only *staying* paused too long triggers the
permanent-looking freeze.
**Fix (FO 0x1fbcf / VA 0x4207cf, + cave at FO 0x66fdf / VA 0x467bdf):** redirect the loop's
back-edge through a small cave that pumps one Windows message per iteration using the game's
**own existing** message-drain function (vtable slot 18 / `0x43ae50`, already called via the
identical pattern at 5 other sites in the binary -- e.g. inside `0x437370`'s screen-transition
handling). This is not new Win32 plumbing; it's invoking an already-proven, already-used
in-binary utility one more time, isolated to this one loop:
```
FO 0x1fbcf (VA 0x4207cf), 6 bytes:
original: 0f 84 c9 fe ff ff ; JZ 0x42069e (loop back to top if TAB still not pressed)
patched: 0f 84 0a 74 04 00 ; JZ 0x467bdf (redirect through the cave below)
FO 0x66fdf (VA 0x467bdf), 12 bytes (previously unused zero-padding after the Patch-V cave):
8b 03 MOV EAX,[EBX] ; EBX holds `this` throughout this function
8b cb MOV ECX,EBX ; fastcall `this` param
ff 50 48 CALL [EAX+0x48] ; vtable slot 18 -- drain/ack pending messages
e9 b3 8a fb ff JMP 0x42069e ; resume exactly where the original branch would have
```
No PE header changes needed -- the Patch-V cave region was already extended once (67 bytes),
and only 17 of those were used, leaving exactly enough free space for this. Does not touch
`DAT_0047c488`, the delta-time clamp, or any of its 203 consumers -- fully isolated from the
FPS-coupling investigation.
**Status:** built as `HAVOC_NOCD_PAUSEFIX_v1.EXE`. Statically verified by re-importing the
patched file into a fresh, independent Ghidra project: both the redirect and the cave
disassemble and decompile exactly as designed, with the injected call appearing as ordinary,
well-formed code identical in shape to the 5 other existing call sites for the same vtable
slot. **Play-tested and confirmed working (2026-07-03):** pause + cheat code + stay paused
15+ seconds -> TAB still unpauses correctly (previously hung permanently). Alt-tab away while
paused, return, unpause -> continues normally (previously looked like a reset).
**Does NOT fix a separate, still-open issue:** alt-tabbing away while **not** paused still
resets to the menu on refocus (confirmed on this same patched binary, same session,
immediately after the above). This is a different symptom with a different, still-unknown
cause -- the pause-loop hang fix only touched the pause-command code path, and
`WM_ACTIVATEAPP` handling runs unconditionally regardless of pause state. See
`docs/FUNCTIONS.md` "Alt-tab / focus-loss investigation" for the current (re-opened) status.
Don't conflate the two -- they looked identical from the outside ("resets to menu") but have
different root causes and only one is fixed so far.
## Reproducing / testing
The crash is launch-environment dependent (it reproduces when launched normally but not under a

28
docs/PATCH_VERSIONS.md Normal file
View file

@ -0,0 +1,28 @@
# Patched Binary Version Log
Tracks every patched `HAVOC_NOCD*.EXE` variant we've built, working or not. All binaries are
gitignored (copyrighted game code) -- this file is the durable record of what was tried.
Non-working / superseded binaries live in `archive/`. The current best-known-working one for
each patch line stays at the repo root so tooling (Ghidra project, scripts) doesn't need
updating every iteration.
| File | Based on | Patch (see `docs/PATCHES.md`) | Status | Notes |
|---|---|---|---|---|
| `HAVOC_NOCD.EXE` | `HAVOC.EXE` | Copy-protection + NOCD patch set (full table in `docs/PATCHES.md`) | **Working, in active use** | The baseline. Boots and plays correctly; FPS-coupling bug still present (mitigated via dgVoodoo config, not a binary patch) |
| `archive/HAVOC_NOCD_FPSFIX_v1.EXE` | `HAVOC_NOCD.EXE` | NOP the 9-byte min-1-tick clamp at FO `0x0871d` | **Broken -- archived** | Crashes on level load. Root cause: only 2 of 203 read sites of the shared delta value (`DAT_0047c488`) were checked for zero-safety before patching; one of the ~38 unaudited consumer functions almost certainly divides by it. See `docs/PATCHES.md` "FPS-coupling" section for the full writeup |
| `HAVOC_NOCD_FPSFIX_v2.EXE` (not yet built) | `HAVOC_NOCD.EXE` | Same 9-byte region, corrected: turn the clamp into a busy-wait spin instead of a lie (see `docs/PATCHES.md`) | *(design only, deprioritized)* | Preserves "return value is never 0" for all 203 read sites while still fixing the actual bug. Paused in favor of the pause-loop hang fix and the "maximized = 2x normal speed" lead, which turned out to be a separate mechanism -- see `docs/FUNCTIONS.md` |
| `HAVOC_NOCD_PAUSEFIX_v1.EXE` | `HAVOC_NOCD.EXE` | Redirect the pause-loop's back-edge through a small code cave that pumps one Windows message per iteration (FO `0x1fbcf` + cave at FO `0x66fdf`, see `docs/PATCHES.md`) | **Working, confirmed by play-test** | Fixes the "greys out / Not Responding, TAB stops working" hang when paused too long, and the "alt-tab while paused resets to menu" symptom (same root cause). Does NOT fix a separate symptom: alt-tab while *not* paused still resets to menu -- that's a different, still-open bug, see `docs/FUNCTIONS.md`. Isolated fix, doesn't touch the FPS-coupling code path |
## Conventions
- Version suffix `_v2`, `_v3`, ... increments per patch *line* (e.g. all FPS-coupling fix
attempts), not globally across every patch ever made to the binary.
- A version only earns a plain (non-archived) spot at the repo root once it's confirmed
working by play-testing. Anything superseded or confirmed broken moves to `archive/`
immediately -- don't leave dead/broken binaries at the root where they could get mixed
up with the current candidate.
- Every entry here should link back to the relevant `docs/PATCHES.md` section for the actual
byte-level detail; this file is the index/status tracker, not the technical writeup.
- Update this table in the same commit/session as any patch build or status change (working
-> broken, experimental -> confirmed, etc).