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.
144 lines
18 KiB
Markdown
144 lines
18 KiB
Markdown
# 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.
|