havoc-remaster/docs/PATCHES.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

233 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# HAVOC_NOCD.EXE — Binary Patches (run on modern Windows without CD)
`HAVOC_NOCD.EXE` is a patched copy of the original `HAVOC.EXE` (485,376 bytes, 32-bit PE i386,
DirectDraw + DirectSound). The patches let it run from a hard-drive install on Windows 11 via
[dgVoodoo2](http://dege.freeweb.hu/) (DirectDraw wrapper, windowed mode), with no CD mounted
and no admin rights.
Do **not** commit `HAVOC_NOCD.EXE` or the original `HAVOC.EXE` (copyright).
## Section layout / file-offset (FO) to virtual-address (VA) mapping
| Section | Raw FO start | RVA | FO → VA formula |
|---|---|---|---|
| `.text` | 0x00400 | 0x01000 | `VA = FO + 0x400C00` |
| `.data` | 0x69400 | 0x7C000 | `VA = FO + 0x412C00` |
| `.rdata` | 0x67000 | 0x79000 | `VA = FO - 0x67000 + 0x79000 + 0x400000` |
| `.idata` | 0x6EC00 | 0x82000 | `VA = FO + 0x413400` |
`.text` VirtualSize was extended from 0x66bbd to 0x66c00 (PE header FO 0x180) so the Patch-V
code cave at VA 0x467bce is mapped into memory.
## Patch table
| FO | VA | Bytes (→) | Purpose |
|---|---|---|---|
| 0x00180 | (PE hdr) | `bd6b`→`006c` | Extend `.text` VirtualSize to cover Patch-V cave |
| 0x094fd | 0x40a0fd | `66 b8 01 00`→`66 33 c0 90` | **Patch S**: CreateSoundBuffer fail returns AX=0 (continue w/o sound) |
| 0x20277 | 0x420e77 | `75`→`eb` | Force a Jcc branch (skip) |
| 0x29613 | 0x42a213 | `8844242c`→`90909090` | **Patch D**: NOP drive-letter overwrite (INTRFACE) |
| 0x29623 | 0x42a223 | `88442410`→`90909090` | **Patch D**: NOP drive-letter overwrite (MUSIC) |
| 0x296cc | 0x42a2cc | `7c`→`eb` | **Patch Q**: skip check |
| 0x29b02 | 0x42a702 | 18 bytes → NOP | Remove drive-type compare/branches |
| 0x29b38 | 0x42a738 | `75`→`eb` | Drive-check skip |
| 0x29b80 | 0x42a780 | `74`→`eb` | CD-check skip |
| 0x29bb0 | 0x42a7b0 | `81ec6c010000`→`b001c39090 90` | NOCD stub = `MOV AL,1; RET` |
| 0x29d63 | 0x42a963 | `75`→`eb` | Extra CD skip |
| 0x29e10 | 0x42aa10 | `74`→`eb` | CD skip 2 |
| **0x29a2b** | **0x42a62b** | `8b442414 85c0 8b442410 752a 85c0``8b442410 85c0 754e 8b442414 eb48` | **Copy-protection / GRAFIX fix** (see below) |
| 0x2cd70 | 0x42d970 | `81`→`c3` | **Patch N3**: `show_error_dialog` returns immediately (silence popups) |
| 0x2cf2e | 0x42db2e | `74`→`eb 16` | Corrupt/unavailable bypass |
| 0x30393 | 0x430f93 | `e888c9ffff`→NOP | NOP a validation CALL |
| 0x42514 | 0x443114 | `b8010000`→`e9b54a02` | **Patch V**: JMP to vtable-setter code cave |
| 0x66fce | 0x467bce | cave | **Patch V** cave: sets vtable=1, returns EAX=1 |
| 0x6b27c | 0x47de7c | `C:\MUSIC.FF`→`MUSIC.FF` | Strip drive prefix |
| 0x6b288 | 0x47de88 | `C:\INTRFACE.FF`→`INTRFACE.FF` | Strip drive prefix |
| 0x6b2b8 | 0x47deb8 | `C:\WORLDS\`→`WORLDS\` | Strip drive prefix |
| 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)
**Symptom:** dgVoodoo window appears for ~1 second, all black, then the process disappears.
**Actual cause:** an ACCESS_VIOLATION (NULL-pointer read) at VA **0x444E6A** during init — found
by parsing the WER minidump in `%LOCALAPPDATA%\CrashDumps` (the crash call stack shows
`0x434073``0x407CCB`, i.e. inside `WinMain → 0x434040 init`).
The WORLDS\ data-file loader `0x42a480` opens each file (`GRAFIX00-03.DAT`, `LAND*`, `MAP*`,
`STUF*`) from **two** locations: the local `WORLDS\<name>` path and a path built from the CD
drive letter in `HAVOC.INI` (`[SETUP] DRIVE=D:\`). Its return logic returns a valid stream
**only when the local open fails and the CD-drive open succeeds** — a copy-protection check that
assumes data lives on the CD. With the files present locally and no `D:` drive, it returns NULL;
the caller `0x444db0` (GRAFIX loader) dereferences that NULL without checking → crash.
**Fix (FO 0x29a2b):** rewrite the return decision to prefer the successfully-opened local object:
```
0x42a62b: mov eax,[esp+0x10] ; local WORLDS\ stream object
0x42a62f: test eax,eax
0x42a631: jnz 0x42a681 ; if local open OK, return it
0x42a633: mov eax,[esp+0x14] ; else return the drive object
0x42a637: jmp 0x42a681
```
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
debugger). Reliable native run:
```bat
:: run_probe.bat
cd /d Z:\Development\devl\Havoc
.\HAVOC_NOCD.EXE
```
Notes:
- `NoDefaultCurrentDirectoryInExePath` may be set — invoke the exe with a `.\` prefix.
- Launching via MSYS/Git-Bash segfaults (exit 139) and does not produce a WER dump; use `cmd`.
- Minidumps can be parsed with plain Python (no cdb/windbg needed) — see the exception stream
(type 6) for the faulting EIP and the memory streams (5/9) to walk the stack.