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.
26 KiB
Havoc RE Methodology: How We Cracked the Formats
This document records the process of reverse-engineering Havoc (Reality Bytes, Inc., 1995), including the wrong turns, so the blog post can tell the full story.
1. Starting point: what do we have?
The game files had been sitting on a network share untouched. The top level:
HAVOC.EXE 485 KB
LAUNCHER.EXE 17 KB
SETUP.EXE 110 KB
BIGFILE.DAT 64 MB
MUSIC.FF ~6 MB
INTRFACE.FF ~12 MB
OBJECTS.FF ~2 MB
SOUND.FF ~1 MB
WORLDS/ terrain data
DIRECTX/ bundled DirectX 5
AUTORUN.INF OPEN=LAUNCHER.EXE
.windows-serial contains "0"
The first question: why did it stop running? The common assumption for old Windows games is "16-bit" or "DOS". That assumption was wrong.
2. Identifying the binaries (correcting the 16-bit assumption)
Approach: read the PE header directly in Python rather than trusting assumptions.
Every Windows executable starts with an MZ stub. At offset 0x3C is a uint32 pointing to
the PE signature. We read that offset for all three EXEs:
HAVOC.EXE PE offset = 0x80
LAUNCHER.EXE PE offset = 0x80
SETUP.EXE PE offset = 0x80
At 0x80 in each: 50 45 00 00 = PE\x00\x00. Confirmed 32-bit PE. The Machine field
(0x014C) = i386. The Magic field (0x010B) = PE32 (not PE32+). Subsystem = 2 (GUI).
Key finding: these are 32-bit Win32 applications. They will run under WOW64 on 64-bit Windows today. The game itself is not the problem.
The import table confirmed the real story: DDRAW.dll, DSOUND.dll, WINMM.dll,
WSOCK32.dll, GDI32.dll, USER32.dll, KERNEL32.dll. Notably: no Direct3D at all.
What actually broke: the bundled DIRECTX/ folder ships DSETUP16.DLL and DDRAW16.DLL
-- genuine 16-bit components used by the installer path. Those cannot run on 64-bit Windows.
But the game doesn't need the installer to run once files are in place.
The second problem: legacy DirectDraw palettized fullscreen (Mode X / 8-bit indexed) is unreliable on modern GPUs without a compatibility wrapper like dgVoodoo2.
Correction during process: we initially described Havoc as "2D". Looking at the screenshots on myabandonware corrected this -- it is a first-person software-rendered 3D vehicular combat game. The software rasterizer blits into a DirectDraw surface, explaining why there is no Direct3D import despite full 3D gameplay.
3. Identifying the archive format (.FF files)
The .FF files are the main asset containers. No magic signature at the start, so we
read the first few bytes as a uint32: 116 for MUSIC.FF. That looked like a file count.
We then parsed speculatively: if byte 4 is an offset and the following bytes are a NUL- terminated filename, does it make sense?
count = struct.unpack_from("<I", data, 0)[0] # 116 for MUSIC.FF
pos = 4
offset = struct.unpack_from("<I", data, pos)[0] # first file offset
name = data[pos+4 : data.index(b"\x00", pos+4)] # "FFCREATE.EXE"
It did. The first entry was FFCREATE.EXE at a reasonable offset, and its first bytes were
MZ -- a real DOS executable. The format was clear:
uint32 file_count (little-endian)
repeat file_count:
uint32 absolute_data_offset (little-endian)
char[] filename (NUL-terminated)
<concatenated file payloads>
Finding FFCREATE.EXE (a DOS MZ binary) embedded inside the archive was a nice confirmation:
this was the tool Reality Bytes used to build the .FF files, shipped alongside the data it
created. The name "FF" almost certainly stands for "FlashFile."
Gotcha: the last TOC entry may have an empty name and offset pointing past the end. Skip it.
4. Cracking MUSIC.FF: the playlist format
MUSIC.FF contains 97 WAVE*.DAT entries and 17 PLST*.DAT entries, plus FFCREATE.EXE.
The WAVE entries were straightforward: RIFF signature, standard PCM WAV, mono 22050 Hz
8-bit. Directly playable with any audio tool.
The PLST (playlist) entries were more interesting. Initial read (little-endian):
first two uint16s of PLST07D1: 0x0004, 0x0014 = (4, 20)
A 4-segment song with 20 playback steps. Then 20 uint16s for the sequence. But when we
tried to unpack them little-endian, the step count came out as 10240 -- clearly wrong for
a 44-byte file. The PLST payload is big-endian despite the .FF container being
little-endian. Swapping to ">HH" gave the correct values.
Segment resolution: a playlist PLST07D1 references segments by 1-based index into a group.
First attempt mapped by the last hex digit of the filename (07D1 -> segment 1, 07D2 -> 2).
This failed for multi-digit segment counts. Correct rule: step N maps to wave ID
base_id + N - 1, where base_id = int("07D1", 16) = 2001. Step 2 -> wave ID 2002 =
WAVE07D2. Verified by assembling all 17 songs and confirming they sound correct.
5. Cracking INTRFACE.FF: the bitmap system
INTRFACE.FF (547 entries) contained several entry types by name prefix:
| Prefix | Count | Hypothesis |
|---|---|---|
BitR |
239 | "Bit Raster" -- raw pixel data |
BitB |
239 | "Bit header/Border" -- paired with BitR by same hex ID |
XPAL/xpal |
~47 | "eXtended PALette" -- 768-byte palette = 256 * RGB |
STR# |
16 | String tables |
RTbl |
3 | "Remap Table" (function TBD) |
FFCREATE.EXE |
1 | Archive builder |
The BitB entries were all exactly 4 bytes. Reading as two big-endian uint16s gave
dimensions: (48, 48) for BitB4E21. Checking BitR4E21: 2304 bytes = 48 * 48. Every
single BitB/BitR pair (239 pairs) matched exactly. Format confirmed.
The BitR data is raw 8-bit palette-indexed pixels, row-major, no header. The XPAL files
are 768 bytes = 256 RGB triplets, 8-bit per channel (NOT 6-bit VGA -- we verified by
checking that ~67% of values exceed 63).
The index-2 black patch (key discovery):
First renders of most screens showed a salmon/pink background (#e78484) instead of the
expected dark industrial look. One screen, 7A12, rendered correctly with a dark background.
Comparing palettes xpal7A12 vs xpal7A1C (same screen layout, different background color):
only 3 of 256 palette entries differed -- indices 0, 1, and 2. In 7A12: index 2 = #000000
(black). In 7A1C: index 2 = #e78484 (salmon). The game engine forces palette index 2 to
black at runtime. The XPAL files store development/authoring-time values; the runtime
patches index 2 before display. One-line fix: pal[6], pal[7], pal[8] = 0, 0, 0 after
loading any XPAL.
Assets extracted from INTRFACE.FF include: 32 full-screen menu/UI backgrounds, cockpit windshield frame layers (three color variants), vehicle selection screens with pre-rendered 3D vehicle thumbnails, cockpit side panel strips, menu button strips in multiple states, and 57 vehicle HUD icons in the game's monochrome green HUD style.
The "minorly off" colors in the renders vs reference screenshots are consistent with CRT gamma -- the palette values were tuned for a 1995 CRT display (~2.2 gamma) and display directly on a modern LCD without gamma compensation. This is a Godot shader concern, not an extraction accuracy issue.
6. Tools used
- Python 3 (stdlib only for format parsing:
struct,os,collections) - Pillow (
PIL) for bitmap rendering and PNG export - ffmpeg (via scoop on Windows) for WAV to OGG/MP3 conversion
- No disassembler yet -- all format work done from data patterns alone
- Ghidra (planned) for game logic / binary RE
7. Music-to-level mapping (from LAND headers)
Each LAND<world><level>.DAT file stores the music track ID at offset 18 as a
big-endian uint16 (the same big-endian convention as the PLST payloads -- a pattern
throughout this codebase).
Discovery process: searched HAVOC.EXE for all MOV WORD PTR [addr], <plst_id> instructions
(66 C7 05 prefix). Found 9 assignments, all writing to address 0x00475C58 (the current
music variable), but only covering 4 unique track IDs. The remaining 13 tracks had to come
from data. Scanning the LAND headers for known PLST IDs at each uint16-aligned offset revealed
offset 18 as the music field -- every level matched a known PLST ID.
Two tracks (1771, 1B59) have zero references in either HAVOC.EXE or LAUNCHER.EXE
and are used by no level file. They are almost certainly cut/multiplayer-lobby tracks.
Track 4269 is hardcoded in HAVOC.EXE near the intro sequence. Track 4651 (Credits)
and 0BB9 (Badlands1) appear in the launcher/menu code paths.
Complete level music table
| World | Level 1 | Level 2 | Level 3 | Boss | Bonus |
|---|---|---|---|---|---|
| 1 Badlands | Badlands1 (0BB9) | Badlands2 (1F41) | Badlands3 (1389) | BossBadlands (07D1) | Bonus (0FA1) |
| 2 Fallout | Fallout1 (32C9) | Fallout2 (3A99) | Fallout3 (36B1) | BossFallout (3E81) | Badlands3 (1389) |
| 3 Tyrak | Tyrak1 (2329) | Tyrak2 (2AF9) | Tyrak3 (2EE1) | BossTyrak (2711) | Bonus (0FA1) |
| 4 Wasteland | Badlands3 (1389) | Badlands2 (1F41) | BossBadlands (07D1) | Badlands1 (0BB9) | Badlands3 (1389) |
| 5 Malterra | Fallout1 (32C9) | Fallout2 (3A99) | Fallout3 (36B1) | BossFallout (3E81) | Bonus (0FA1) |
| 6 Overlord | Tyrak3 (2EE1) | Tyrak2 (2AF9) | Tyrak1 (2329) | BossTyrak (2711) | Badlands3 (1389) |
Other tracks: Intro (4269, EXE intro), Credits (4651), Multiplayer1 (1771, unused), Multiplayer2 (1B59, unused).
Verification note: Badlands3 (1389) assembles correctly from its 5 segments (52.5s total, 20-step PLST) but is notably shorter than other tracks. Tyrak1 is a similar length (53.3s) so this may be intentional. Cross-reference against an emulated playthrough (dgVoodoo2 or Win98 VM) to confirm in-level behavior and loop point.
Worlds 2 and 5 share identical music. Worlds 3 and 6 use the same tracks in reversed order.
World names (from EXE string table + player memory)
Found at offset 0x06A7C4 in HAVOC.EXE, six NUL-terminated world names stored in reverse world order
(Overlord, Wasteland, Malterra, Tyrak, Fallout, Badlands):
| World | Name | Biome |
|---|---|---|
| 1 | Badlands | Desert |
| 2 | Fallout | Ice/Snow |
| 3 | Tyrak | Alien |
| 4 | Wasteland | Desert (harder) |
| 5 | Malterra | Ice (harder) |
| 6 | Overlord | Alien (harder) |
The reverse storage order is consistent with a reverse-indexed lookup table (index 0 = highest world).
Network play confirms the three biome names separately: Badlands Net, Fallout Net, Tyrak Net.
The ice/snow biome is named "Fallout" in the EXE despite its visual aesthetic.
8. What's next
SOUND.FF: same format as MUSIC.FF, just raw SFX WAVs (no playlists)WORLDS/: terrain data --MAP*.MAP,LAND*.DAT,GRAFIX*.DAT,STUF*.DATOBJECTS.FF: 3D models -- the hard one;ASND*entries TBDBIGFILE.DAT: 64 MB bulk data pool (starts with copyright string then RIFF data)- Ghidra decompilation of
HAVOC.EXEfor 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.
-
Ghidra ships prebuilt zips on GitHub releases -- no build step needed:
curl -s https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest \ | grep browser_download_urlGot
Ghidra 12.1.2(ghidra_12.1.2_PUBLIC_20260605.zip). -
Ghidra 12.x requires JDK 21. No system Java was installed and
aptneeds 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):curl -sL -o jdk21.tar.gz \ "https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" -
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/ -
Verified with the headless analyzer (no GUI needed for decompilation work on a headless box --
analyzeHeadlesscovers import + auto-analysis + scripting):export JAVA_HOME=~/tools/jdk-21.0.11+10 export PATH=$JAVA_HOME/bin:$PATH ~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless -help -
Sanity check: imported
HAVOC_NOCD.EXEinto a scratch headless project and ran auto-analysis, just to confirm the toolchain itself worked before touching anything real:~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ ~/ghidra-projects HavocRemaster \ -import /Library/Development/devl/Havoc/HAVOC_NOCD.EXE \ -analysisTimeoutPerFile 600Succeeded 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. -
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.repdatabase, 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) totools/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 pyr0ballGhidra stores the creating user's name in
HAVOC.rep/project.prpand 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,OWNERfrompyr0ballto the Linux username (alan) -- purely project metadata, doesn't touch the analysis database:sed -i 's/VALUE="pyr0ball"/VALUE="alan"/' tools/ghidra_project/HAVOC.rep/project.prpAfter that, both opening the project and running
DecompileHavoc.javaagainst it worked headlessly:~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ tools/ghidra_project HAVOC -process HAVOC_NOCD.EXE -noanalysis \ -scriptPath tools/ghidra_scripts -postScript DecompileHavoc.javaFound and closed: the project's binary was not byte-identical to the current
HAVOC_NOCD.EXEon disk. The project file was namedHAVOC_NOCD.EXEand was imported from//10.1.10.10/Library/Development/devl/Havoc/HAVOC_NOCD.EXE(per Ghidra's recordedEXECUTABLE_PATH), but its recordedEXECUTABLE_MD5(d7a12ab7...) matched none of the threeHAVOC*.EXEfiles 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 intools/ghidra_scripts/, reportedUSER_NAMED_FUNCTIONS: 0of 987 -- pure auto-analysis, no renamed functions or comments), so the fix was a clean re-import rather than a careful merge:~/tools/ghidra_12.1.2_PUBLIC/support/analyzeHeadless \ tools/ghidra_project HAVOC \ -import /Library/Development/devl/Havoc/HAVOC_NOCD.EXE \ -overwrite -analysisTimeoutPerFile 600-overwritereplaces the existing same-named program with a fresh import + full auto-analysis (39s). Re-runningPrintProgramMD5.javaafterward confirmedEXECUTABLE_MD5: e68fb854876232989951c2d813eaaff0, matchingmd5sum HAVOC_NOCD.EXEexactly -- the project is now analyzing the current, verified-working NOCD build, andtools/ghidra_decompile.txtwas 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:
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.