diff --git a/.gitignore b/.gitignore index 20de9e2..98d796e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,14 +24,18 @@ tmp_cd/ # --- Third-party (dgVoodoo2 DirectDraw wrapper) - not ours to redistribute --- dgVoodoo.conf +dgVoodoo*.conf dgVoodooCpl.exe ddraw.dll DSETUP*.DLL +# --- Archived/experimental patched binaries (still original game code, copyright) --- +archive/ + # --- Ghidra: install is third-party; project + decompile output embed the binary --- ghidra/ tools/ghidra_*/ -tools/ghidra_decompile.txt +tools/ghidra_decompile*.txt # --- Derived audio / extracted assets (also copyrighted) --- tools/out_*/ @@ -65,3 +69,6 @@ tools/refs/ *.swp .DS_Store Thumbs.db + +# --- Tool session cruft --- +.playwright-mcp/ diff --git a/docs/FUNCTIONS.md b/docs/FUNCTIONS.md new file mode 100644 index 0000000..24c46ff --- /dev/null +++ b/docs/FUNCTIONS.md @@ -0,0 +1,144 @@ +# Havoc Function Tracker + +Progress tracker for reverse-engineered functions in `HAVOC_NOCD.EXE`, kept in git so it's +diffable and gives an honest "how much of this binary do we actually understand" number for +the blog post. This is a curated list of functions worth naming/understanding, not a full +dump of all ~987 functions Ghidra's auto-analysis found. + +**Status legend:** +- `unanalyzed` -- known to exist (an address we care about), not yet decompiled/read +- `analyzed` -- decompiled and read, behavior understood, still has a Ghidra default `FUN_*` name +- `named` -- renamed in the Ghidra project (`tools/ghidra_project/`) to reflect what it does +- `documented` -- named *and* has a writeup in one of the other `docs/*.md` files + +Update this table whenever a function moves between these states. VA = virtual address +(runtime address with relocations applied; see `docs/HANDOFF.md` section 5 for FO->VA +formulas per section). + +## Boot / init / main loop + +| VA | Name | Status | Notes | +|---|---|---|---| +| `0x464657` | CRT entry point | documented | See `docs/HANDOFF.md` section 5 | +| `0x407C90` | WinMain | analyzed | Allocs game object, calls init (`0x434040`), then main loop (`0x43AC40`) | +| `0x434040` | main init | analyzed | Allocs ~30 subsystem objects, loads title/GAMIMAGE, palette fade, calls `0x418600` | +| `0x418600` | DirectDraw + archives + sound init | unanalyzed | GRAFIX/world files load during this | +| `0x43AC40` | `main_game_loop` (vtable[7]) | named | State machine, state byte at `[this+0x18]`. No frame limiter -> FPS-coupled. See `docs/METHODOLOGY.md` section 10 | +| `0x436170` | `per_frame_vtable8` | named | Calls `frame_tick_counter_while_playing` then conditionally two more vtable calls gated on state/flags | +| `0x4361d0` | `per_frame_vtable9_state_machine` | named | Sets the 60Hz tick baseline (`DAT_0047e56c`) on first call; handles state transitions 0x47/0x49/0xa1 | +| `0x43adc0` | `msg_pump` (vtable[17]) | named | Linked-list event queue drain, dispatches via `[this]+0x3c` | +| `0x40C8E0` | `busywait_frame_limiter_60hz` | named | ~60/sec via `timeGetTime`; used in palette-fade / state transitions, NOT the gameplay loop | +| `0x40CAE0` | `state_transition_with_limiter` | named | Palette-fade / state-transition context that calls the limiter | +| `0x418870` | `frame_tick_counter_while_playing` | named | Calls `dsound_stream_pump` then, if state==0x47, increments a counter at `this+0x158` | + +## FPS-coupling investigation (docs/HANDOFF.md section 7.1) + +| VA | Name | Status | Notes | +|---|---|---|---| +| `0x0047da88` (data) | game/window object ptr | analyzed | Same object as `this` in the main loop `0x43AC40` -- confirmed by reading its vtable at `0x4791A0` and matching slots 7/8/9/17 to the known main-loop callees. Assigned once in `FUN_00426560` (a window-init routine: `ShowWindow`/`UpdateWindow`/`SetWindowLongA`, offset `+0x943` = HWND) | +| offset `+0x92a` | **CORRECTED: common base "Window" class active/shown flag, not cockpit/HUD-specific** | analyzed | Scanned all instructions program-wide for this offset (`ScanForOffset.java`, since it's a struct offset not a fixed global address) -- found ~29 distinct functions across a huge address range (`0x415262` to `0x4624e0`) reading/writing it. This is a shared base-class member used by many menu/dialog/window objects throughout the UI system, not specific to the top-level game object or to cockpit/HUD mode. Retracts the earlier "possible cockpit/HUD-mode flag" guess | +| `0x426170` / `0x4264d0` / `0x426470` | base Window class `Init` / `Show` / `Hide` | analyzed | Found clustered together (same class impl). `0x426170`: constructor-like, zeroes a large field block, sets `+0x92a=0`. `0x4264d0` (**Show**): calls vtable[31] (`+0x7c`) then sets `+0x92a=1`. `0x426470` (**Hide**): sets `+0x92a=0`, calls `0x418a30`, conditional sub-object cleanup. This is the base class every menu/dialog screen likely inherits -- relevant to the keyboard-reassignment graphics bug investigation below | +| vtable slot `0x54` (21, `0x4188c0`) on the base vtable `0x4791A0` | base-class default stub, likely NOT a bug on its own | analyzed | Still just `MOV AX,1 / RET` on the *base* class. Given `+0x92a` is now known to be a common "active" flag checked by many classes, this being a no-op default for the plain base/top-level window is plausible and not necessarily wrong -- the real per-menu-screen override would live on each menu's own derived vtable, not this one. Not re-litigated as the FPS-coupling mechanism (still correctly ruled out for that), but also not confirmed as unrelated to the keyboard-menu bug -- would need to find the *keyboard-reassignment dialog's own object/vtable* specifically, not the top-level game object's | +| `0x418870` | `frame_tick_counter_while_playing` | named | Calls `dsound_stream_pump()` unconditionally, then if state==0x47 (playing) increments a counter at `this+0x158`. Not the physics update itself | +| `0x40a3f0` | `dsound_stream_pump` -- **dead end for physics** | named | References `"DSound Error: SoundBuf->Lock fail..."` / `"...BackgroundBuf->Lock..."` strings and `Lock`/`Unlock`-style vtable calls. This is double-buffered audio streaming, not physics | +| `0x40ecb0` | `player_turn_smoothing_substep` (called only while state==0x47) | named | Takes `DAT_0047c488` (see below) and runs a proportional-smoothing loop `DAT_0047c488` times on a value at `param_1+0xbd`/`+0x121` (steering/turn smoothing?). Confirms `DAT_0047c488` is consumed as a sub-step count, i.e. treated as elapsed ticks | +| `0x40e980` | `player_cooldown_timers_tick` (called only while state==0x47) | named | Subtracts `DAT_0047c488` from several countdown timers on the player/vehicle object (`param_1+0x1a1/0x1a5/0x1a9/0x1ad` -- cooldowns/animation timers), firing side effects (sound events via `DAT_0046ea30`/`DAT_0047c038` vtables) when they expire | +| **`0x4092d0`** | **`compute_frame_delta_ticks_60hz_min1_clamp` -- THE key find** | named | `DAT_0047c488 = FUN_004092d0()` is called once per frame from `0x40e980`. Converts `timeGetTime()` (ms) to a 60Hz tick count (`ms*60/1000`), diffs against the stored value from last call, returns the tick delta. **Min-1 clamp:** `if (iVar2 == 0) iVar2 = 1;` -- if the game is rendering faster than 60fps, less than one 60Hz tick elapses between calls (delta rounds to 0), but the function clamps the minimum return value to 1 tick anyway. Net effect: above 60fps, simulation time is credited faster than real time, in direct proportion to render rate -- a clean, **deterministic linear speedup**, not glitchy/non-deterministic breakage (confirmed by the user: "physics does not break above 60fps, it's deterministic and doesn't seem to behave any differently than 'FASTER'" -- exactly what this clamp predicts). At or below 60fps the delta is accurate (real elapsed ticks, never hits the clamp), which is exactly why the dgVoodoo `FPSLimit=30` workaround works. This is a real per-frame delta-time system with one specific min-clamp causing the speedup, not a naive "no delta timing at all" -- worth correcting the `docs/HANDOFF.md` characterization of "no frame limiter = FPS-coupled" to the more precise "has delta timing, but a >60fps min-tick clamp makes simulation speed scale with framerate above that threshold" | +| `0x0047c488` (data) | per-frame delta value (ticks) | analyzed | Recomputed by `0x4092d0` each call; consumed by `0x40ecb0` (sub-step loop count) and `0x40e980` (timer decrement amount) | +| `0x435610` (vtable slot 12) | unanalyzed | unanalyzed | Real function (not a stub) on the same vtable, unexplored | +| `0x437370` (vtable slot 15) | unanalyzed | unanalyzed | Real function (not a stub) on the same vtable, unexplored | +| **Open question, REVISED** | cockpit ("normal") vs. full-viewport ("maximized") speed difference | unanalyzed | User testing (2026-07-03) with `FPSLimit=0` + `[DirectX] ForceVerticalSync=true` (both views now genuinely vsync-locked to the same real 60fps): **"normal" now runs at exactly the correct speed; "maximized" runs at exactly 2x normal's speed** -- and the 2x ratio is unchanged from before the dgVoodoo tuning (only "normal"'s absolute speed was fixed by the config change). Since both views are confirmed at the *same actual framerate* now, the render-cost/"naturally throttled below 60fps" theory from `docs/PATCHES.md` **cannot explain a persisting 2x gap** -- framerate parity should mean parity in the `compute_frame_delta_ticks_60hz_min1_clamp` clamp behavior too, since neither view exceeds 60fps anymore. This points instead to a genuine **code-level difference**: something in the full-viewport/"maximized" path likely calls the per-frame update/tick machinery (`player_cooldown_timers_tick`/`player_turn_smoothing_substep`, or whatever drives them) an extra time per rendered frame, independent of actual framerate. Not yet located -- the `+0x92a` flag and its vtable-slot-21 gate (still just a base-class stub on the object checked so far) remain a lead, but given the flag is a generic base-class field used by ~29 unrelated functions, the real mode-switch code is more likely in whatever toggles between the cockpit-dashboard render path and the full-viewport render path (bound to the `+` key) -- not yet found. **Do not re-attempt the previous FPS patch approach (min-clamp removal) to explain this** -- that was a different, already-reverted-as-unsafe mechanism (see "FPS-coupling" section in `docs/PATCHES.md`) and doesn't explain a framerate-independent 2x multiplier anyway | + +## Alt-tab / focus-loss investigation + +Triggered by a real symptom report: menu appears to "reset" when alt-tabbing out and back in +while running via dgVoodoo2. Also connects to a second observation: the game renders as a +borderless-but-resizable window, not true OS fullscreen or a titled window. + +| VA | Name | Status | Notes | +|---|---|---|---| +| `0x40b5b0` | `wndproc_dispatch` | named | Main WndProc. Handles `WM_DESTROY` (2, sets state=0xf0), `WM_ACTIVATEAPP` (0x1c), `WM_SETFOCUS`-family (7/0xf), plus DirectDraw palette/mouse messages (0x201/0x202/0x203) and custom app messages 0x401-0x405 | +| `0x436070` | `on_deactivate_force_pause_state` (vtable slot 13 / `0x34`) | named | On `WM_ACTIVATEAPP(0)`: skips if state==0xf0 (exiting). Otherwise backs up current state to `DAT_0047e578`, force-sets state to `0xB0` (pause), calls `0x40a8b0`, a vtable[23] call on a secondary object, then `state_transition_with_limiter` | +| `0x4360e0` | `on_reactivate_restore_state` (vtable slot 14 / `0x38`) | named | On `WM_ACTIVATEAPP(1)`: **correctly restores** the state backed up in `DAT_0047e578`. Calls `ddraw_full_display_reinit`, then vtable[30]/[34] calls on a secondary object, conditionally `0x40a910`, then `dsound_stream_pump`, then `state_transition_with_limiter`. The state machine itself is NOT the bug -- it round-trips cleanly | +| `0x40bd30` | `ddraw_full_display_reinit` | named | **The actual heavy-lifter.** Full DirectDraw teardown/rebuild: `SetDisplayMode`, `CreateSurface` (primary+back, with retry loop on `DDERR_SURFACELOST`/`DDERR_WASSTILLDRAWING`), `Lock`, `CreatePalette`. This is the exclusive-fullscreen "recreate everything after losing the display device" path, not a lightweight `IDirectDrawSurface::Restore()`. Explains both symptoms: (1) the visible "reset" flash on alt-tab-back-in is a real full surface recreation, and (2) it only makes sense if the game requested `DDSCL_EXCLUSIVE\|DDSCL_FULLSCREEN` -- which is consistent with dgVoodoo2 presenting it as a borderless-but-resizable window rather than a normal titled window (dgVoodoo's own rendering of a wrapped exclusive-fullscreen DirectDraw app) | +| **Conclusion, PARTIALLY REVISED (two distinct symptoms, not one)** | state machine confirmed not buggy; two separate "reset" triggers exist | -- | Internal game state survives alt-tab correctly at the state-machine level (confirmed originally, still true). Two *different* real-world symptoms both got called "resets to menu" during testing, and they have different causes: **(1) alt-tab while paused, staying away/paused too long** -- this was the pause-loop message-queue hang (see `docs/PATCHES.md` "Pause-loop hang"); confirmed fixed by `HAVOC_NOCD_PAUSEFIX_v1.EXE` (2026-07-03 test: alt-tab away while paused, return, unpause -> continues normally). **(2) alt-tab while NOT paused** -- user confirmed (2026-07-03, same session, same `HAVOC_NOCD_PAUSEFIX_v1.EXE` binary) this STILL resets to the menu on refocus, so it is a genuinely separate, still-unresolved issue -- the pause-loop fix did not touch it. Since the WM_ACTIVATEAPP handlers (`on_deactivate_force_pause_state`/`on_reactivate_restore_state`) run unconditionally on every alt-tab regardless of pause state, and we already confirmed those two functions round-trip the state variable correctly, the cause of symptom (2) is still open -- likely either in `ddraw_full_display_reinit`'s surface recreation actually failing to restore the correct visual content (not just the state int), or some other object beyond the one game-state variable we've traced so far. **Do not assume this is resolved** -- re-open investigation if picked back up | +| **Tested** | mouse input in menu, fresh launch vs. post-alt-tab | -- | User confirmed (2026-07-03): mouse responds correctly in the menu both on fresh launch and after alt-tabbing. An earlier report of "menu doesn't respond to mouse" was transient/not reproducible -- not a real regression from the `ddraw.dll` permission fix, the `FPSLimit`/`ForceVerticalSync` tuning, or this investigation | + +## Keyboard-reassignment menu graphics bug (first genuine bug found this session) + +Symptom: on the "Change Keyboard Settings" screen, the dynamic elements (the list of game +functions + their currently-assigned keys, since the static menu chrome loads fine) flash +for a fraction of a second on menu load, then disappear and never redraw. + +Confirmed the string table for this screen: `INTRFACE.FF` resource `STR#7A62` +(`tools/out_intrface/raw/STR#7A62.dat`): `"Press a key to assign to selected game function." / +"Cancel" / "Save Settings" / "Restore Default Settings"`. The parent Options menu is +`STR#7A44`: `"Change Game Settings" / "Change Viewscreen Settings" / "Change Keyboard +Settings" / "Change Joystick Settings" / "Return to Main Screen"`. + +**Initial hypothesis (retracted, see the `+0x92a` correction above):** thought the +per-frame vtable[21] stub on the top-level game object was the missing dynamic-redraw call. +Scanning showed `+0x92a` is a common base "Window" class active-flag used by ~29 different +functions across the codebase -- not specific to this menu. The stub at `0x4188c0` is the +*base class's* default and is plausibly correct/intentional there; it doesn't mean every +derived window's slot 21 is a stub. + +**Progress: found the Options-menu construction function as a template.** `0x4596c0` +(scanned via the `0x7a44` resource-ID search) builds the whole "Change Game/Viewscreen/ +Keyboard/Joystick Settings" screen: allocates a button widget per string ID starting at +`0x7a45` (looping, each with its own vtable -- `PTR_LAB_004795b0`, `PTR_LAB_004794e0`, +`PTR_FUN_00479610`, `PTR_LAB_00479448`, `PTR_LAB_00479478`, `PTR_FUN_00479070` are all +distinct widget-subclass vtables: buttons, labels, sliders), sets a title via `0x426240` +(likely `SetTitleString(resourceId)`), then finishes with `FUN_00426240(param_1, 0x7a44)` +followed by `FUN_004264d0(param_1)` -- **that's the base-class `Show` method we identified**, +called on the newly-built screen object itself. Confirms the general pattern: every menu +screen is its own object instance with its own per-widget vtables, constructed by a +dedicated "build this screen" function, finished off by the common `Show`. + +Also found `0x458350`, a widget message handler that references `0x7a62` (computing a +string-ID offset based on a selection index for what looks like a status/tooltip line, matching +STR#7A44's `"Move the mouse over a button for a description of its function."`) -- likely a +*shared* button/widget message handler reused across multiple screens, not specific to the +keyboard-reassignment screen's own construction. + +**Still open, next step:** find the keyboard-reassignment screen's own construction function +-- the equivalent of `0x4596c0` but building buttons/labels from string IDs starting around +`0x7a63` (the item after `STR#7A62`'s header string) instead of `0x7a45`. Once found, check +whether its per-widget-type vtables (for whatever draws the dynamic key-binding list +specifically) have a real per-frame update/redraw implementation or a stub -- that's where +the actual bug should surface. Given "flash once then disappear" (not "never drawn at all"), +the initial static draw works -- something about the *per-frame* redraw path for this +screen's dynamic list specifically is what's missing or broken. + +## Copy-protection / file loading (docs/PATCHES.md) + +| VA | Name | Status | Notes | +|---|---|---|---| +| `0x0042a480` | WORLDS\ data-file loader | documented | The copy-protection check function; patched. See `docs/PATCHES.md` | +| `0x0042a880` | fn_init_file_objects | analyzed | | +| `0x0042db00` | delete_savegame | analyzed | `DeleteFileA` wrapper | +| `0x0042dab0` | strcpy_to_obj | analyzed | | +| `0x0042d920` | error_display_6 | analyzed | | +| `0x0042d970` | show_error_dialog | documented | Patched to `RET` (Patch N3) to silence popups | +| `0x00438d80` | get_strdata_string | analyzed | | +| `0x004274e0` | alloc_obj | analyzed | | +| `0x00444db0` | GRAFIX0N.DAT loader | documented | Byte-swaps BE records; template for other WORLDS loaders. See `docs/FORMATS.md` | + +## Not yet started (from docs/HANDOFF.md section 7) + +| Target | Status | Notes | +|---|---|---| +| Cheat code handler (`mmm`/`vvv`/`sss`/`hhh`/`aaa`) | unanalyzed | Find the keyboard-buffer matcher | +| Weapon system (6 primary + 14 secondary) | unanalyzed | See `docs/WEAPONS.md` for the string-table side | +| Player/vehicle physics | unanalyzed | | +| Enemy AI | unanalyzed | | +| MAP/LAND/STUF loaders | unanalyzed | Also finishes the asset-cracking stream | + +## How to update this + +1. Decompile with the headless scripts in `tools/ghidra_scripts/` (e.g. `DecompileFPS.java`, + `DecompileHavoc.java` -- extend their `TARGET_VAS` arrays, or add a new script for a new + investigation topic). +2. Read the output in `tools/ghidra_decompile*.txt`, update the row's status here. +3. If you rename a function in the Ghidra GUI/project, bump status to `named` here too. +4. If it gets a real writeup elsewhere in `docs/`, bump to `documented` and link it. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index cb85556..7b9c5d2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -45,11 +45,17 @@ write down the *discovery steps and dead ends*, not just the final spec. Keep no `docs/FORMATS.md`. - **MAP layout partially decoded:** `MAP0101.MAP` is a 2-byte-per-cell grid, ~192x144 (384-byte rows, found via row autocorrelation). Cell semantics (which byte is tile vs height) still TBD. -- **Known open gameplay issue:** movement/physics is **FPS-coupled** (one sim step per rendered - frame, no fixed timestep). Workaround in place: dgVoodoo `FPSLimit` set to 30 (tunable). The - user's memory says the in-game "minimal" HUD mode was NOT FPS-coupled while "cockpit" mode was, - which suggests a limiter/delta path already exists in the binary for one mode. Finding that in - Ghidra is a priority (see section 7). +- **FPS-coupling: root cause found.** It's not "no timestep at all" -- there's a real + delta-time system (`compute_frame_delta_ticks_60hz_min1_clamp` @ `0x4092D0`, converts + `timeGetTime()` to 60Hz ticks correctly), but it clamps its minimum return value to 1 tick. + Above 60fps, less real time elapses between calls than one tick, so the clamp still credits + a full tick -- simulation speed scales up in deterministic, linear proportion to render + rate above that threshold (confirmed against the user's own description: "does not break + ... deterministic ... behaves ... 'FASTER'"). Below/at 60fps the delta is accurate, which + is exactly why dgVoodoo `FPSLimit=30` fixes it. Whether this differs between cockpit and + minimal-HUD modes is still unconfirmed -- the functions found so far gate only on game + state, not HUD mode. Full chase incl. two dead ends in `docs/METHODOLOGY.md` section 10; + function-by-function status in `docs/FUNCTIONS.md`. --- @@ -65,7 +71,7 @@ Tooling used this session and how it maps to Linux: | Python | `C:/Python313/python.exe` | `python3` | | Disassembly | `radare2` + `r2pipe` (pip) | same: `apt/pacman install radare2`, `pip install r2pipe` | | Image output | Pillow (`pip install pillow`) | same | -| Decompiler | (not yet used) | **Ghidra** (Java, cross-platform) - the main Linux task | +| Decompiler | Ghidra 12.1.2 GUI (project at `tools/ghidra_project/`) | Ghidra 12.1.2 headless (see below) - now set up | | Run the game | dgVoodoo2 on native Windows | Wine + dgVoodoo (test only; not needed for static work) | **Windows-only tools that do NOT port (and are no longer needed):** the crash diagnosis used WER @@ -79,6 +85,36 @@ All the extraction tools (`ff_extract.py`, `grafix_extract.py`) are cross-platfo **Note:** the game itself only runs on Windows (or Wine). Linux sessions are for Ghidra + asset extraction, which need only the files, not a running game. +**Ghidra is now installed on Linux (`Heimdall`, headless, no sudo needed):** +```sh +export JAVA_HOME=~/tools/jdk-21.0.11+10 +export PATH=$JAVA_HOME/bin:$PATH +~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ + /Library/Development/devl/Havoc/tools/ghidra_project HAVOC \ + -process HAVOC_NOCD.EXE -noanalysis \ + -scriptPath /Library/Development/devl/Havoc/tools/ghidra_scripts \ + -postScript DecompileHavoc.java +``` +`tools/ghidra_project/` is the **existing, real, previously-analyzed project** from an +earlier Windows/GUI session (gitignored, so invisible to `git status` - check the +filesystem). Do not create a new project. Full setup story (dead ends, the `NotOwnerException` +fix) is in `docs/METHODOLOGY.md` section 9. `tools/ghidra_scripts/DecompileHavoc.java` is a +working headless script that dumps decompiled C for a curated VA list to +`tools/ghidra_decompile.txt` - extend its `TARGET_VAS` array with new addresses as the +section 7 targets below get investigated. No GUI/display is available on this box; all +Ghidra work here is headless-only (scripting + batch decompile), not interactive +click-through. + +**Resolved:** the project's binary was initially found to be a stale, superseded NOCD patch +iteration (MD5 mismatch vs. the current `HAVOC_NOCD.EXE`). Confirmed no manual annotation +existed to lose (0 of 987 functions user-named), so it was re-imported with `-overwrite` and +fully re-analyzed against the current binary. `PrintProgramMD5.java` (in `tools/ +ghidra_scripts/`) confirms the project's `EXECUTABLE_MD5` now matches `md5sum +HAVOC_NOCD.EXE` exactly. `tools/ghidra_decompile.txt` has been regenerated against the +verified-current, working NOCD build. If `HAVOC_NOCD.EXE` gets repatched again, re-run the +`-overwrite` import (see `docs/METHODOLOGY.md` section 9 point 6) before trusting decompiled +output. + --- ## 4. Repo, git, and constraints diff --git a/docs/LEVEL_NOTES.md b/docs/LEVEL_NOTES.md new file mode 100644 index 0000000..39bd4c4 --- /dev/null +++ b/docs/LEVEL_NOTES.md @@ -0,0 +1,40 @@ +# Level / AI Design Notes + +Running list of level-specific design quirks and idiosyncrasies noticed during play-testing, +separate from `docs/PATCHES.md` (binary bugs/fixes) and `docs/FUNCTIONS.md` (RE progress). +These are candidates for the **Remaster** variant's "bug fixes" bucket (see `docs/HANDOFF.md` +section 1) while staying intact in the **Faithful 1:1** variant. Nothing here has been +reverse-engineered yet -- these are gameplay observations to revisit once the AI/level-loader +code (MAP/STUF/LAND, enemy AI) gets decompiled. + +## World 1 (Badlands), first level + +**Unreachable stranded drop from a flying enemy.** One of the final flying enemies in this +level has a patrol/movement pattern that can carry it over a tall piece of terrain. If it +isn't killed early -- before most other enemies in that area -- it can end up dying while +positioned over that tall terrain, and its item drop can land somewhere physically +unreachable (stuck on/above terrain the player can't get to). + +- Not a crash or technical bug -- a level/AI design issue remembered from the original game, + confirmed again during a 2026-07-03 playthrough. +- Workaround (as originally learned): kill this specific enemy early, before it can drift + into the problem area. +- **Remaster candidate:** once `STUF.DAT` (entity placement) and the AI/pathing code are + decompiled, this could be fixed by adjusting the enemy's patrol bounds, or by making drops + from airborne kills fall to the nearest reachable ground instead of wherever the enemy died. +- Not yet reverse-engineered -- no addresses, no code path identified. Revisit once AI/level + loading is a priority (see `docs/HANDOFF.md` section 7, items 4-5). + +## Template for new entries + +``` +## World (), level + +**Short description.** Longer explanation of the quirk, when it happens, how to reproduce +or avoid it. + +- Bug or design quirk? +- Reproduction notes / workaround if known. +- Remaster candidate? What would the fix look like. +- RE status: not yet investigated / found at VA 0x... / etc. +``` diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index d299d63..e766afb 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -243,3 +243,242 @@ The ice/snow biome is named "Fallout" in the EXE despite its visual aesthetic. - `OBJECTS.FF`: 3D models -- the hard one; `ASND*` entries TBD - `BIGFILE.DAT`: 64 MB bulk data pool (starts with copyright string then RIFF data) - Ghidra decompilation of `HAVOC.EXE` for game logic, AI, cheat codes, weapon system + +## 9. Ghidra environment setup (Linux, headless, no sudo) + +Standing up Ghidra on the Linux dev box (`Heimdall`, headless -- no `$DISPLAY`, no +passwordless `sudo`) this session. Recorded because the obvious paths didn't work. + +**Dead end #1: the existing `ghidra/` checkout.** The repo already had a `ghidra/` +directory (already gitignored) containing a full clone of the NSA `ghidra` *source* repo -- +not a build, not a submodule of this repo, just a bare clone. It was owned `nobody:nogroup` +with no write bit for the working user, so it couldn't be reused, modified, or removed +without root. Building Ghidra from source also requires the full Gradle + JDK toolchain, +which is unnecessary work when prebuilt releases exist. Left in place untouched; a prebuilt +install was set up alongside it instead. + +**Dead end #2: `nobody:nogroup` ownership was repo-wide, not just `ghidra/`.** Most of the +repo (docs, tools, the game files themselves) came up owned `nobody:nogroup` too, blocking +writes. Cause: this repo lives on NFS-mounted storage (`/Library`, mounted from `Navi`), and +files saved onto it from the Windows box in earlier sessions landed with squashed +`nobody:nogroup` ownership rather than mapping to the Linux user. Fixed with +`sudo chown -R alan:alan` from a session with the actual sudo password (this session didn't +have one) -- not a code/tooling issue, just an NFS cross-platform ownership quirk to expect +whenever files get written from the Windows side and then edited from Linux. + +**Working approach: prebuilt release + local JDK, entirely in `$HOME`, zero sudo.** + +1. Ghidra ships prebuilt zips on GitHub releases -- no build step needed: + ```sh + curl -s https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest \ + | grep browser_download_url + ``` + Got `Ghidra 12.1.2` (`ghidra_12.1.2_PUBLIC_20260605.zip`). + +2. Ghidra 12.x requires JDK 21. No system Java was installed and `apt` needs sudo, so a + Temurin JDK 21 tarball was pulled directly from Adoptium's API (no root needed -- + it's just an archive extracted to `$HOME`): + ```sh + curl -sL -o jdk21.tar.gz \ + "https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" + ``` + +3. Both extracted under `~/tools/` (outside the repo entirely -- this is a personal + toolchain install, not project data): + ``` + ~/tools/ghidra_12.1.2_PUBLIC/ + ~/tools/jdk-21.0.11+10/ + ``` + +4. Verified with the headless analyzer (no GUI needed for decompilation work on a headless + box -- `analyzeHeadless` covers import + auto-analysis + scripting): + ```sh + export JAVA_HOME=~/tools/jdk-21.0.11+10 + export PATH=$JAVA_HOME/bin:$PATH + ~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless -help + ``` + +5. Sanity check: imported `HAVOC_NOCD.EXE` into a scratch headless project and ran + auto-analysis, just to confirm the toolchain itself worked before touching anything + real: + ```sh + ~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ + ~/ghidra-projects HavocRemaster \ + -import /Library/Development/devl/Havoc/HAVOC_NOCD.EXE \ + -analysisTimeoutPerFile 600 + ``` + Succeeded in 43 seconds (Decompiler Parameter ID, Switch Analysis, and the usual PE + analyzers all ran clean; the only notable log line was `MinGW pseudo-relocation list not + found`, harmless -- the binary just isn't a MinGW build). This scratch project was + deleted once step 6 below made it redundant. + +6. **Found the real project already existed.** `tools/ghidra_project/` turned out to hold + a genuine, previously-analyzed Ghidra project from an earlier Windows/GUI session -- + `HAVOC.gpr` + an 8.4 MB `.rep` database, same Ghidra version (12.1.2) as just installed + here. There's also a working custom headless script, `tools/ghidra_scripts/ + DecompileHavoc.java`, which decompiles a curated list of known-interesting VAs (the + copy-protection / file-loader functions from section 5) to `tools/ghidra_decompile.txt`. + Both are gitignored (`tools/ghidra_*/`, `tools/ghidra_decompile.txt`) so they never + showed up as tracked files, only as directories on disk -- easy to miss. + + Opening it directly failed at first: + ``` + ghidra.util.NotOwnerException: Project is owned by pyr0ball + ``` + Ghidra stores the creating user's name in `HAVOC.rep/project.prp` and headless mode + refuses to touch a project owned by a different user (GUI mode would prompt; headless + just aborts). Fix was a one-line edit of that XML file, `OWNER` from `pyr0ball` to the + Linux username (`alan`) -- purely project metadata, doesn't touch the analysis database: + ```sh + sed -i 's/VALUE="pyr0ball"/VALUE="alan"/' tools/ghidra_project/HAVOC.rep/project.prp + ``` + After that, both opening the project and running `DecompileHavoc.java` against it + worked headlessly: + ```sh + ~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ + tools/ghidra_project HAVOC -process HAVOC_NOCD.EXE -noanalysis \ + -scriptPath tools/ghidra_scripts -postScript DecompileHavoc.java + ``` + + **Found and closed: the project's binary was not byte-identical to the current + `HAVOC_NOCD.EXE` on disk.** The project file was *named* `HAVOC_NOCD.EXE` and was + imported from `//10.1.10.10/Library/Development/devl/Havoc/HAVOC_NOCD.EXE` (per Ghidra's + recorded `EXECUTABLE_PATH`), but its recorded `EXECUTABLE_MD5` (`d7a12ab7...`) matched + none of the three `HAVOC*.EXE` files then on disk (`HAVOC.EXE`, `HAVOC_NOCD.EXE`, + `HAVOC_NOCD.bak.EXE` -- all different MD5s from each other too). It was an earlier + iteration of the NOCD patch, superseded since the project was last saved on Windows. + + Confirmed zero manual annotation existed to lose (`ListCustomNames.java`, a one-off + script in `tools/ghidra_scripts/`, reported `USER_NAMED_FUNCTIONS: 0` of 987 -- pure + auto-analysis, no renamed functions or comments), so the fix was a clean re-import rather + than a careful merge: + ```sh + ~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ + tools/ghidra_project HAVOC \ + -import /Library/Development/devl/Havoc/HAVOC_NOCD.EXE \ + -overwrite -analysisTimeoutPerFile 600 + ``` + `-overwrite` replaces the existing same-named program with a fresh import + full + auto-analysis (39s). Re-running `PrintProgramMD5.java` afterward confirmed + `EXECUTABLE_MD5: e68fb854876232989951c2d813eaaff0`, matching `md5sum HAVOC_NOCD.EXE` + exactly -- the project is now analyzing the current, verified-working NOCD build, and + `tools/ghidra_decompile.txt` was regenerated against it. + +**Takeaway for future sessions:** don't start a fresh Ghidra project. `tools/ghidra_project/` +is the canonical one (its `.gpr`/`.rep` are gitignored, so `git status` won't show it -- +check the filesystem directly). It now matches the current `HAVOC_NOCD.EXE` (verified by +MD5, see above). If `HAVOC_NOCD.EXE` gets repatched again, re-run the `-overwrite` import +above and re-check `PrintProgramMD5.java` before trusting decompiled output. If a +*different* machine/user ever needs to open the project headlessly, expect a +`NotOwnerException` and fix it the same way (edit `OWNER` in `HAVOC.rep/project.prp`). + +**Why headless instead of the GUI:** `Heimdall` has no X server. Ghidra's GUI needs a +display (X11 forwarding, VNC, or a local machine); the headless analyzer does not. +`analyzeHeadless` covers import, auto-analysis, and running Jython/Java post-scripts against +an existing project, which is enough to continue the decompilation stream started on +Windows. Interactive exploration of specific functions (the actual "click through the +decompiler" workflow) will need either a GUI session on a machine with a display, or a +remote-GUI approach (VNC/X11 forwarding) set up in a later session if headless scripting +proves too limiting. + +## 10. FPS-coupling investigation + +The user's report: movement/physics runs *faster* at high framerates -- deterministically, +not glitchy, just "faster" -- and capping the framerate (dgVoodoo `FPSLimit=30`) fixes it. +`docs/HANDOFF.md` section 7.1 asked whether this was a naive "one sim step per rendered +frame, no timestep at all" bug, and specifically whether a "minimal HUD" render mode used a +different, uncoupled timing path than "cockpit" mode. This section is the actual chase, +worked entirely with the headless Ghidra scripts in `tools/ghidra_scripts/` (one-off scripts +per step, since each step depended on what the previous one found). + +**Step 1: confirm the main loop has no limiter.** Decompiled the known main-loop address +(`0x43AC40`, now named `main_game_loop`) and its per-frame callees from `docs/HANDOFF.md` +section 5 (`0x436170`, `0x4361D0`, `0x43ADC0`, plus the known limiter `0x40C8E0`/`0x40CAE0` +for comparison). Confirmed: the loop body calls its per-frame vtable methods with no +`Sleep`/`timeGetTime` gate in between -- matches the existing note that the busy-wait +limiter is used elsewhere (palette fades, state transitions) but not here. + +**Dead end #1: a promising-looking gated per-frame call turned out to be a no-op stub.** +The loop conditionally calls `(**(code**)(*DAT_0047da88 + 0x54))(frame_count)` only when a +byte flag at `DAT_0047da88+0x92a` is set. This looked exactly like a mode toggle (cockpit vs. +minimal HUD gating an extra per-frame call). Cross-referencing `DAT_0047da88` (via Ghidra's +`ReferenceManager`, filtering to `WRITE`-type refs) found it's assigned once, in a +window-init routine (`ShowWindow`/`UpdateWindow`/`SetWindowLongA` -- clearly the main game +window object), and its vtable read back as `0x4791A0` -- the same vtable table documented +for the main-loop `this` object, confirmed by cross-checking known slots (7/8/9/17 all +matched). So `DAT_0047da88` is the same object/class as the main loop's `this`. Resolved +vtable slot 21 (offset `0x54`) to `0x4188C0` -- but Ghidra hadn't disassembled it as a +function at all (no code there yet). Forced disassembly (`createFunction`) revealed it's +just `MOV AX,1 / RET`. A trivial default stub, not physics. Whether the `+0x92a` flag is +actually the cockpit/HUD toggle is still unconfirmed either way -- the call it gates just +doesn't do anything interesting in this build, so this path was abandoned. + +**Dead end #2: the other unconditional per-frame call is the audio streamer.** Vtable slot 8 +(`0x436170`, `per_frame_vtable8`) unconditionally calls `0x418870` every frame. Decompiling +that led to `0x40A3F0`, which turned out to be double-buffered DirectSound streaming (string +references to `"DSound Error: SoundBuf->Lock fail..."` etc., `Lock`/`Unlock`-shaped vtable +calls). Not physics, just the music/audio buffer refill pump. `0x418870` itself just +increments a frame counter at `this+0x158` while state==0x47 ("playing"). + +**The actual find: `per_frame_vtable8` also calls two player-object functions only while +playing** (`0x40ECB0` and `0x40E980`, called from within `if (*(int*)(param_1+0x18)==0x47)` +in the earlier decompile). Both take the player/vehicle object pointer (`DAT_0047e42c`) and +both consume a shared global, `DAT_0047c488`, as an elapsed-ticks value: +`0x40ECB0` (`player_turn_smoothing_substep`) runs a proportional-smoothing loop +`DAT_0047c488` times; `0x40E980` (`player_cooldown_timers_tick`) subtracts `DAT_0047c488` +from several countdown timers on the player object. Both treating a shared global as "ticks +elapsed since last frame" is the signature of a real delta-time value -- worth finding what +computes it. + +**`DAT_0047c488` is computed by `0x4092D0`, now named `compute_frame_delta_ticks_60hz_min1_clamp`:** +```c +int compute_frame_delta_ticks_60hz_min1_clamp(void) { + iVar2 = DAT_0047c48c; // ticks stored from the previous call + if (DAT_0047c48c == 0) { // first call: establish baseline + DAT_0047c48c = timeGetTime() * 60 / 1000; + iVar2 = 0; + } else { + iVar2 = timeGetTime() * 60 / 1000 - iVar2; // delta ticks since last call + DAT_0047c48c = DAT_0047c48c + iVar2; + } + if (iVar2 == 0) { iVar2 = 1; } // <-- the clamp + return iVar2; +} +``` +This *is* real delta-time: `timeGetTime()` (wall-clock ms) is converted to a 60Hz tick count +and diffed against the previous call, same pattern as the known 60Hz baseline at +`DAT_0047e56c`. At or below 60fps, calls are spaced >= 1 tick apart in real time, so the +delta is accurate and the clamp never triggers. **Above 60fps, less than one 60Hz tick +elapses between calls -- the true delta rounds to 0 -- but the function clamps the minimum +return value to 1 tick anyway.** Net effect: above the 60fps threshold, the game credits at +least 1 tick of simulated time per call regardless of how little real time actually passed, +so simulation speed scales up in direct, deterministic proportion to render rate. That +matches the user's own description exactly ("physics does not break above 60fps, it's +deterministic and doesn't seem to behave any differently than 'FASTER'") -- this is a clean +linear speedup from a min-clamp, not jitter or desync from a missing timestep. It also +explains why `FPSLimit=30` fixes it: staying under 60fps never reaches the clamp. + +This corrects the framing in `docs/HANDOFF.md` section 2/7.1 ("no frame limiter = FPS-coupled", +implying no delta timing at all) to the more precise mechanism: **there is real delta-time +timing, gated by a specific >60fps minimum-tick clamp** in `compute_frame_delta_ticks_60hz_min1_clamp`. + +**Open question, not yet resolved:** whether this clamp behaves differently between cockpit +and minimal-HUD modes. `player_turn_smoothing_substep` and `player_cooldown_timers_tick` +both gate only on `state==0x47` (playing), not on any HUD-mode flag found so far -- so on +current evidence this speedup should affect both modes equally, which doesn't yet explain +the user's original memory that minimal-HUD mode wasn't FPS-coupled. Either that memory was +about a different symptom (e.g. cockpit-only visual sway/bob integration, separately naive), +or the real position/rotation integration -- not yet located, distinct from the turn-smoothing +and cooldown-timer functions found here -- is the mode-dependent piece. Next step if this is +picked back up: find what actually writes the player object's position/orientation each +frame and check whether it takes a mode-dependent path. See `docs/FUNCTIONS.md` for the full +function-by-function status. + +**Methodology note:** every function in this chase was found by decompiling one function, +reading what it called next, and following that thread -- exactly the "read the loader" +approach from section 8, just applied to the render loop instead of a file loader. The two +dead ends (the vtable stub, the audio pump) were only recognizable as dead ends *after* +decompiling them; there was no way to rule them out from the disassembly summary alone. All +functions found here were renamed in the live Ghidra project (`tools/ghidra_project/`) via +one-off `RenameOne.java`/`RenameFPSFunctions.java` scripts, so a GUI session opening the same +project later inherits these names for free. diff --git a/docs/PATCHES.md b/docs/PATCHES.md index 8c99ebc..a6d944a 100644 --- a/docs/PATCHES.md +++ b/docs/PATCHES.md @@ -47,6 +47,9 @@ code cave at VA 0x467bce is mapped into memory. | 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) @@ -76,6 +79,142 @@ the caller `0x444db0` (GRAFIX loader) dereferences that NULL without checking 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 diff --git a/docs/PATCH_VERSIONS.md b/docs/PATCH_VERSIONS.md new file mode 100644 index 0000000..3d7de14 --- /dev/null +++ b/docs/PATCH_VERSIONS.md @@ -0,0 +1,28 @@ +# Patched Binary Version Log + +Tracks every patched `HAVOC_NOCD*.EXE` variant we've built, working or not. All binaries are +gitignored (copyrighted game code) -- this file is the durable record of what was tried. + +Non-working / superseded binaries live in `archive/`. The current best-known-working one for +each patch line stays at the repo root so tooling (Ghidra project, scripts) doesn't need +updating every iteration. + +| File | Based on | Patch (see `docs/PATCHES.md`) | Status | Notes | +|---|---|---|---|---| +| `HAVOC_NOCD.EXE` | `HAVOC.EXE` | Copy-protection + NOCD patch set (full table in `docs/PATCHES.md`) | **Working, in active use** | The baseline. Boots and plays correctly; FPS-coupling bug still present (mitigated via dgVoodoo config, not a binary patch) | +| `archive/HAVOC_NOCD_FPSFIX_v1.EXE` | `HAVOC_NOCD.EXE` | NOP the 9-byte min-1-tick clamp at FO `0x0871d` | **Broken -- archived** | Crashes on level load. Root cause: only 2 of 203 read sites of the shared delta value (`DAT_0047c488`) were checked for zero-safety before patching; one of the ~38 unaudited consumer functions almost certainly divides by it. See `docs/PATCHES.md` "FPS-coupling" section for the full writeup | +| `HAVOC_NOCD_FPSFIX_v2.EXE` (not yet built) | `HAVOC_NOCD.EXE` | Same 9-byte region, corrected: turn the clamp into a busy-wait spin instead of a lie (see `docs/PATCHES.md`) | *(design only, deprioritized)* | Preserves "return value is never 0" for all 203 read sites while still fixing the actual bug. Paused in favor of the pause-loop hang fix and the "maximized = 2x normal speed" lead, which turned out to be a separate mechanism -- see `docs/FUNCTIONS.md` | +| `HAVOC_NOCD_PAUSEFIX_v1.EXE` | `HAVOC_NOCD.EXE` | Redirect the pause-loop's back-edge through a small code cave that pumps one Windows message per iteration (FO `0x1fbcf` + cave at FO `0x66fdf`, see `docs/PATCHES.md`) | **Working, confirmed by play-test** | Fixes the "greys out / Not Responding, TAB stops working" hang when paused too long, and the "alt-tab while paused resets to menu" symptom (same root cause). Does NOT fix a separate symptom: alt-tab while *not* paused still resets to menu -- that's a different, still-open bug, see `docs/FUNCTIONS.md`. Isolated fix, doesn't touch the FPS-coupling code path | + +## Conventions + +- Version suffix `_v2`, `_v3`, ... increments per patch *line* (e.g. all FPS-coupling fix + attempts), not globally across every patch ever made to the binary. +- A version only earns a plain (non-archived) spot at the repo root once it's confirmed + working by play-testing. Anything superseded or confirmed broken moves to `archive/` + immediately -- don't leave dead/broken binaries at the root where they could get mixed + up with the current candidate. +- Every entry here should link back to the relevant `docs/PATCHES.md` section for the actual + byte-level detail; this file is the index/status tracker, not the technical writeup. +- Update this table in the same commit/session as any patch build or status change (working + -> broken, experimental -> confirmed, etc).