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.
233 lines
14 KiB
Markdown
233 lines
14 KiB
Markdown
# 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.
|