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

14 KiB
Raw Permalink Blame History

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 (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) bd6b006c Extend .text VirtualSize to cover Patch-V cave
0x094fd 0x40a0fd 66 b8 01 0066 33 c0 90 Patch S: CreateSoundBuffer fail returns AX=0 (continue w/o sound)
0x20277 0x420e77 75eb Force a Jcc branch (skip)
0x29613 0x42a213 8844242c90909090 Patch D: NOP drive-letter overwrite (INTRFACE)
0x29623 0x42a223 8844241090909090 Patch D: NOP drive-letter overwrite (MUSIC)
0x296cc 0x42a2cc 7ceb Patch Q: skip check
0x29b02 0x42a702 18 bytes → NOP Remove drive-type compare/branches
0x29b38 0x42a738 75eb Drive-check skip
0x29b80 0x42a780 74eb CD-check skip
0x29bb0 0x42a7b0 81ec6c010000b001c39090 90 NOCD stub = MOV AL,1; RET
0x29d63 0x42a963 75eb Extra CD skip
0x29e10 0x42aa10 74eb CD skip 2
0x29a2b 0x42a62b 8b442414 85c0 8b442410 752a 85c08b442410 85c0 754e 8b442414 eb48 Copy-protection / GRAFIX fix (see below)
0x2cd70 0x42d970 81c3 Patch N3: show_error_dialog returns immediately (silence popups)
0x2cf2e 0x42db2e 74eb 16 Corrupt/unavailable bypass
0x30393 0x430f93 e888c9ffff→NOP NOP a validation CALL
0x42514 0x443114 b8010000e9b54a02 Patch V: JMP to vtable-setter code cave
0x66fce 0x467bce cave Patch V cave: sets vtable=1, returns EAX=1
0x6b27c 0x47de7c C:\MUSIC.FFMUSIC.FF Strip drive prefix
0x6b288 0x47de88 C:\INTRFACE.FFINTRFACE.FF Strip drive prefix
0x6b2b8 0x47deb8 C:\WORLDS\WORLDS\ Strip drive prefix
0x6b390 0x47df90 C:\WORLDS\LAND0101.DATWORLDS\... Strip drive prefix
0x6b3a8 0x47dfa8 C:\HAVOCDAT.DATHAVOCDAT.DAT Strip drive prefix
0x6b3b8 0x47dfb8 C:\BIGFILE.DATBIGFILE.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 ff0f 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 0x4340730x407CCB, 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:

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:

:: 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.