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.
484 lines
26 KiB
Markdown
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.
|