Hardware watchpoint on game_object+0x18 (runtime addr 0x2361064) fired during active gameplay via MCP debug agent. Write at 0x4360A7 (MOV [ECX+18h], 0xB0) inside on_deactivate_force_pause_state. Confirms existing static analysis: - eax=0x47 (old gameplay state read just before write) - esi=0x1c (WM_ACTIVATEAPP -- exact match to documented WM message) - DAT_0047E578=0x47 (prev-state global saved correctly) - game_object+0x20=0x47 (target state still 0x47, round-trip intact) State 0xB0 (pause/deactivate) was already documented; this adds live confirmation. The still-open symptom-2 (reset to menu on re-focus during gameplay) is not in this state write -- it lies further along in ddraw_full_display_reinit. Also records Item 3 watchpoint result: watchpoint on DAT_0047c488 fired at 0x40E998 (MOV [DAT_0047c488], EAX in player_cooldown_timers_tick -- the store is in the caller, not inside compute_frame_delta_ticks_60hz_min1_clamp). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
145 lines
19 KiB
Markdown
145 lines
19 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 |
|
|
| **Live-confirmed (2026-07-04)** | state transition 0x47->0xB0 on alt-tab, MCP debug agent | -- | Hardware watchpoint on `game_object+0x18` (runtime addr `0x2361064`) fired during active gameplay (state==0x47). Write at `0x4360A7` (`MOV [ECX+18h], 0xB0`) inside `on_deactivate_force_pause_state`. At stop: `eax=0x47` (old state just read), `esi=0x1c` (WM_ACTIVATEAPP - exact match), `DAT_0047E578=0x47` (prev-state global saved as expected), `game_object+0x20=0x47` (target state still 0x47). State machine round-trips correctly - confirms existing static analysis. The still-unresolved symptom-2 (reset to menu on re-focus during gameplay) is not in this state write; it lies further along in `on_reactivate_restore_state`/`ddraw_full_display_reinit`. |
|
|
|
|
## 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.
|