# 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\` 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.