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.
14 KiB
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) | 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 |
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:
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:
NoDefaultCurrentDirectoryInExePathmay 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.