havoc-remaster/docs/METHODOLOGY.md
pyr0ball 763509e4bc feat: set up Linux Ghidra pipeline, find and patch FPS-coupling and pause-hang bugs
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.
2026-07-03 22:32:28 -07:00

484 lines
26 KiB
Markdown

# 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?
```python
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*.DAT`
- `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.