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
19 KiB
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/readanalyzed-- decompiled and read, behavior understood, still has a Ghidra defaultFUN_*namenamed-- renamed in the Ghidra project (tools/ghidra_project/) to reflect what it doesdocumented-- named and has a writeup in one of the otherdocs/*.mdfiles
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
- Decompile with the headless scripts in
tools/ghidra_scripts/(e.g.DecompileFPS.java,DecompileHavoc.java-- extend theirTARGET_VASarrays, or add a new script for a new investigation topic). - Read the output in
tools/ghidra_decompile*.txt, update the row's status here. - If you rename a function in the Ghidra GUI/project, bump status to
namedhere too. - If it gets a real writeup elsewhere in
docs/, bump todocumentedand link it.