From 27174a1c79275a28af5be532de0f694bf3ae8390 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 3 Jul 2026 09:29:59 -0700 Subject: [PATCH] feat: NOCD patch boots Havoc on modern Windows (defeats CD copy protection) Diagnosed and fixed the crash that killed HAVOC_NOCD.EXE ~1s after launch (dgVoodoo window appears black, then the process exits). It was not a clean exit: an ACCESS_VIOLATION (null-pointer read at VA 0x444E6A) during init. Root cause is a copy-protection check in the WORLDS\ data-file loader (0x42a480). It opens each file (GRAFIX/LAND/MAP/STUF) from both a local "WORLDS\" path and a CD-drive path (HAVOC.INI [SETUP] DRIVE=D:\), and returns a valid stream only when the local open fails and the CD-drive open succeeds. With files present locally and no D: drive it returned NULL, and the GRAFIX loader (0x444db0) dereferenced that NULL without checking. Fix: 14-byte patch at FO 0x29a2b rewrites the return decision to hand back the successfully-opened local object. The game now boots to the title screen ("HAVOC(tm) by Reality Bytes"), responsive, main loop running. - docs/PATCHES.md: full patch table (28 patches) + crash write-up - tools/: RE + patching scripts (r2pipe disasm, minidump parser, ctypes debugger, PE/IAT/import analysis) - run_probe.bat / run_and_log.bat: reliable native launch for repro - .gitignore: exclude CD images, Ghidra install/project, dgVoodoo, *.ini Diagnosed via WER minidumps (%LOCALAPPDATA%\CrashDumps) parsed in pure Python (no cdb/windbg available). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv --- .gitignore | 18 +++++ docs/FORMATS.md | 25 ++++++- docs/METHODOLOGY.md | 58 +++++++++++++++- docs/PATCHES.md | 94 +++++++++++++++++++++++++ docs/WEAPONS.md | 94 +++++++++++++++++++++++++ run_and_log.bat | 4 ++ run_probe.bat | 6 ++ tools/analyze2.py | 49 +++++++++++++ tools/analyze3.py | 66 ++++++++++++++++++ tools/analyze4.py | 60 ++++++++++++++++ tools/analyze5.py | 58 ++++++++++++++++ tools/analyze6.py | 59 ++++++++++++++++ tools/analyze7.py | 51 ++++++++++++++ tools/analyze_crash.py | 50 ++++++++++++++ tools/check_files.py | 69 +++++++++++++++++++ tools/check_strings.py | 45 ++++++++++++ tools/createfile_trace.py | 88 ++++++++++++++++++++++++ tools/dbg_path.py | 137 +++++++++++++++++++++++++++++++++++++ tools/deep_audit.py | 103 ++++++++++++++++++++++++++++ tools/deep_trace.py | 93 +++++++++++++++++++++++++ tools/drive_trace.py | 106 ++++++++++++++++++++++++++++ tools/dump_region.py | 109 +++++++++++++++++++++++++++++ tools/final_analysis.py | 116 +++++++++++++++++++++++++++++++ tools/find_callers.py | 37 ++++++++++ tools/find_cdcheck.py | 39 +++++++++++ tools/find_cdpath.py | 48 +++++++++++++ tools/find_drive_func.py | 62 +++++++++++++++++ tools/find_ini_calls.py | 82 ++++++++++++++++++++++ tools/find_string.py | 20 ++++++ tools/full_audit.py | 120 ++++++++++++++++++++++++++++++++ tools/ini_audit.py | 110 +++++++++++++++++++++++++++++ tools/io_audit.py | 57 +++++++++++++++ tools/parse_iat.py | 112 ++++++++++++++++++++++++++++++ tools/parse_imports.py | 80 ++++++++++++++++++++++ tools/patch_exappwindow.py | 114 ++++++++++++++++++++++++++++++ tools/patch_flagcheck.py | 36 ++++++++++ tools/patch_paths.py | 51 ++++++++++++++ tools/patch_vtable.py | 112 ++++++++++++++++++++++++++++++ tools/path_audit.py | 52 ++++++++++++++ tools/pe_sections.py | 67 ++++++++++++++++++ tools/r2ac00.py | 22 ++++++ tools/r2alloc.py | 26 +++++++ tools/r2alloc2.py | 29 ++++++++ tools/r2alloc3.py | 19 +++++ tools/r2analyze.py | 36 ++++++++++ tools/r2cdcheck.py | 18 +++++ tools/r2crash.py | 23 +++++++ tools/r2heap.py | 47 +++++++++++++ tools/r2init.py | 22 ++++++ tools/read_bin_iso.py | 95 +++++++++++++++++++++++++ tools/trace_init.py | 48 +++++++++++++ 51 files changed, 3140 insertions(+), 2 deletions(-) create mode 100644 docs/PATCHES.md create mode 100644 docs/WEAPONS.md create mode 100644 run_and_log.bat create mode 100644 run_probe.bat create mode 100644 tools/analyze2.py create mode 100644 tools/analyze3.py create mode 100644 tools/analyze4.py create mode 100644 tools/analyze5.py create mode 100644 tools/analyze6.py create mode 100644 tools/analyze7.py create mode 100644 tools/analyze_crash.py create mode 100644 tools/check_files.py create mode 100644 tools/check_strings.py create mode 100644 tools/createfile_trace.py create mode 100644 tools/dbg_path.py create mode 100644 tools/deep_audit.py create mode 100644 tools/deep_trace.py create mode 100644 tools/drive_trace.py create mode 100644 tools/dump_region.py create mode 100644 tools/final_analysis.py create mode 100644 tools/find_callers.py create mode 100644 tools/find_cdcheck.py create mode 100644 tools/find_cdpath.py create mode 100644 tools/find_drive_func.py create mode 100644 tools/find_ini_calls.py create mode 100644 tools/find_string.py create mode 100644 tools/full_audit.py create mode 100644 tools/ini_audit.py create mode 100644 tools/io_audit.py create mode 100644 tools/parse_iat.py create mode 100644 tools/parse_imports.py create mode 100644 tools/patch_exappwindow.py create mode 100644 tools/patch_flagcheck.py create mode 100644 tools/patch_paths.py create mode 100644 tools/patch_vtable.py create mode 100644 tools/path_audit.py create mode 100644 tools/pe_sections.py create mode 100644 tools/r2ac00.py create mode 100644 tools/r2alloc.py create mode 100644 tools/r2alloc2.py create mode 100644 tools/r2alloc3.py create mode 100644 tools/r2analyze.py create mode 100644 tools/r2cdcheck.py create mode 100644 tools/r2crash.py create mode 100644 tools/r2heap.py create mode 100644 tools/r2init.py create mode 100644 tools/read_bin_iso.py create mode 100644 tools/trace_init.py diff --git a/.gitignore b/.gitignore index be600d4..20de9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,30 @@ *.MAP *.ICO *.INF +*.INI +*.ini .windows-serial BIGFILE.DAT WORLDS/ DIRECTX/ LICENSE.TXT +# --- CD images (copyright) --- +*.cue +*.bin +tmp_cd/ + +# --- Third-party (dgVoodoo2 DirectDraw wrapper) - not ours to redistribute --- +dgVoodoo.conf +dgVoodooCpl.exe +ddraw.dll +DSETUP*.DLL + +# --- Ghidra: install is third-party; project + decompile output embed the binary --- +ghidra/ +tools/ghidra_*/ +tools/ghidra_decompile.txt + # --- Derived audio / extracted assets (also copyrighted) --- tools/out_*/ tools/Havoc_*/ diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 920097d..94e551d 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -86,6 +86,15 @@ salmon background) revealed only 3 differing entries out of 256 -- indices 0, 1, Length-prefixed strings for the screen's UI labels. First byte = string length, then UTF-8 text. Example: `STR#797C` contains all main menu button labels and the copyright notice. +Two special-purpose string tables live in `INTRFACE.FF` at fixed IDs: + +| ID | Content | +|---|---| +| `7D02` | Pickup banner text -- all item and weapon pickup notification strings | +| `7D03` | HUD active weapon name display strings | + +See `docs/WEAPONS.md` for the complete weapon list derived from these tables. + ### Identified screens (640x480 full-screen bitmaps) | ID | Content (from STR# strings) | @@ -117,7 +126,21 @@ text. Example: `STR#797C` contains all main menu button labels and the copyright ## World data (`WORLDS/`) — TODO (3D terrain) -Per-level sets `` = 01..06 plus `N`/`N2` variants (biomes: desert, snow/ice, lava, ocean). +Per-level sets `` = 01..06. Six worlds confirmed from EXE string table + player memory: + +| File prefix | World name | Biome | +|---|---|---| +| `LAND01xx` | Badlands | Desert | +| `LAND02xx` | Fallout | Ice/Snow | +| `LAND03xx` | Tyrak | Alien | +| `LAND04xx` | Wasteland | Desert (harder) | +| `LAND05xx` | Malterra | Ice (harder) | +| `LAND06xx` | Overlord | Alien (harder) | + +Level suffix codes within each world: `01`=Level 1, `02`=Level 2, `03`=Level 3, `04`=Boss, `0B`=Bonus. +Network variants use `0N`, `1N`, `2N` prefixes for the three biomes at Easy/Medium/Hard. + +Music track ID is stored at **offset 18 as big-endian uint16** in each `LAND*.DAT` file. | Pattern | Guess | Status | |---|---|---| diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index 84ab1f1..d299d63 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -180,7 +180,63 @@ an extraction accuracy issue. --- -## 7. What's next +## 7. Music-to-level mapping (from LAND headers) + +Each `LAND.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], ` 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` diff --git a/docs/PATCHES.md b/docs/PATCHES.md new file mode 100644 index 0000000..8c99ebc --- /dev/null +++ b/docs/PATCHES.md @@ -0,0 +1,94 @@ +# HAVOC_NOCD.EXE — Binary Patches (run on modern Windows without CD) + +`HAVOC_NOCD.EXE` is a patched copy of the original `HAVOC.EXE` (485,376 bytes, 32-bit PE i386, +DirectDraw + DirectSound). The patches let it run from a hard-drive install on Windows 11 via +[dgVoodoo2](http://dege.freeweb.hu/) (DirectDraw wrapper, windowed mode), with no CD mounted +and no admin rights. + +Do **not** commit `HAVOC_NOCD.EXE` or the original `HAVOC.EXE` (copyright). + +## Section layout / file-offset (FO) to virtual-address (VA) mapping + +| Section | Raw FO start | RVA | FO → VA formula | +|---|---|---|---| +| `.text` | 0x00400 | 0x01000 | `VA = FO + 0x400C00` | +| `.data` | 0x69400 | 0x7C000 | `VA = FO + 0x412C00` | +| `.rdata` | 0x67000 | 0x79000 | `VA = FO - 0x67000 + 0x79000 + 0x400000` | +| `.idata` | 0x6EC00 | 0x82000 | `VA = FO + 0x413400` | + +`.text` VirtualSize was extended from 0x66bbd to 0x66c00 (PE header FO 0x180) so the Patch-V +code cave at VA 0x467bce is mapped into memory. + +## Patch table + +| FO | VA | Bytes (→) | Purpose | +|---|---|---|---| +| 0x00180 | (PE hdr) | `bd6b`→`006c` | Extend `.text` VirtualSize to cover Patch-V cave | +| 0x094fd | 0x40a0fd | `66 b8 01 00`→`66 33 c0 90` | **Patch S**: CreateSoundBuffer fail returns AX=0 (continue w/o sound) | +| 0x20277 | 0x420e77 | `75`→`eb` | Force a Jcc branch (skip) | +| 0x29613 | 0x42a213 | `8844242c`→`90909090` | **Patch D**: NOP drive-letter overwrite (INTRFACE) | +| 0x29623 | 0x42a223 | `88442410`→`90909090` | **Patch D**: NOP drive-letter overwrite (MUSIC) | +| 0x296cc | 0x42a2cc | `7c`→`eb` | **Patch Q**: skip check | +| 0x29b02 | 0x42a702 | 18 bytes → NOP | Remove drive-type compare/branches | +| 0x29b38 | 0x42a738 | `75`→`eb` | Drive-check skip | +| 0x29b80 | 0x42a780 | `74`→`eb` | CD-check skip | +| 0x29bb0 | 0x42a7b0 | `81ec6c010000`→`b001c39090 90` | NOCD stub = `MOV AL,1; RET` | +| 0x29d63 | 0x42a963 | `75`→`eb` | Extra CD skip | +| 0x29e10 | 0x42aa10 | `74`→`eb` | CD skip 2 | +| **0x29a2b** | **0x42a62b** | `8b442414 85c0 8b442410 752a 85c0` → `8b442410 85c0 754e 8b442414 eb48` | **Copy-protection / GRAFIX fix** (see below) | +| 0x2cd70 | 0x42d970 | `81`→`c3` | **Patch N3**: `show_error_dialog` returns immediately (silence popups) | +| 0x2cf2e | 0x42db2e | `74`→`eb 16` | Corrupt/unavailable bypass | +| 0x30393 | 0x430f93 | `e888c9ffff`→NOP | NOP a validation CALL | +| 0x42514 | 0x443114 | `b8010000`→`e9b54a02` | **Patch V**: JMP to vtable-setter code cave | +| 0x66fce | 0x467bce | cave | **Patch V** cave: sets vtable=1, returns EAX=1 | +| 0x6b27c | 0x47de7c | `C:\MUSIC.FF`→`MUSIC.FF` | Strip drive prefix | +| 0x6b288 | 0x47de88 | `C:\INTRFACE.FF`→`INTRFACE.FF` | Strip drive prefix | +| 0x6b2b8 | 0x47deb8 | `C:\WORLDS\`→`WORLDS\` | Strip drive prefix | +| 0x6b390 | 0x47df90 | `C:\WORLDS\LAND0101.DAT`→`WORLDS\...` | Strip drive prefix | +| 0x6b3a8 | 0x47dfa8 | `C:\HAVOCDAT.DAT`→`HAVOCDAT.DAT` | Strip drive prefix | +| 0x6b3b8 | 0x47dfb8 | `C:\BIGFILE.DAT`→`BIGFILE.DAT` | Strip drive prefix | + +## The copy-protection / GRAFIX crash (the "black window then closes" bug) + +**Symptom:** dgVoodoo window appears for ~1 second, all black, then the process disappears. + +**Actual cause:** an ACCESS_VIOLATION (NULL-pointer read) at VA **0x444E6A** during init — found +by parsing the WER minidump in `%LOCALAPPDATA%\CrashDumps` (the crash call stack shows +`0x434073` → `0x407CCB`, i.e. inside `WinMain → 0x434040 init`). + +The WORLDS\ data-file loader `0x42a480` opens each file (`GRAFIX00-03.DAT`, `LAND*`, `MAP*`, +`STUF*`) from **two** locations: the local `WORLDS\` path and a path built from the CD +drive letter in `HAVOC.INI` (`[SETUP] DRIVE=D:\`). Its return logic returns a valid stream +**only when the local open fails and the CD-drive open succeeds** — a copy-protection check that +assumes data lives on the CD. With the files present locally and no `D:` drive, it returns NULL; +the caller `0x444db0` (GRAFIX loader) dereferences that NULL without checking → crash. + +**Fix (FO 0x29a2b):** rewrite the return decision to prefer the successfully-opened local object: + +``` +0x42a62b: mov eax,[esp+0x10] ; local WORLDS\ stream object +0x42a62f: test eax,eax +0x42a631: jnz 0x42a681 ; if local open OK, return it +0x42a633: mov eax,[esp+0x14] ; else return the drive object +0x42a637: jmp 0x42a681 +``` + +After this patch the game runs (window title "HAVOC(tm) by Reality Bytes", responsive, main +loop executing) instead of crashing. + +## Reproducing / testing + +The crash is launch-environment dependent (it reproduces when launched normally but not under a +debugger). Reliable native run: + +```bat +:: run_probe.bat +cd /d Z:\Development\devl\Havoc +.\HAVOC_NOCD.EXE +``` + +Notes: +- `NoDefaultCurrentDirectoryInExePath` may be set — invoke the exe with a `.\` prefix. +- Launching via MSYS/Git-Bash segfaults (exit 139) and does not produce a WER dump; use `cmd`. +- Minidumps can be parsed with plain Python (no cdb/windbg needed) — see the exception stream + (type 6) for the faulting EIP and the memory streams (5/9) to walk the stack. diff --git a/docs/WEAPONS.md b/docs/WEAPONS.md new file mode 100644 index 0000000..55757ea --- /dev/null +++ b/docs/WEAPONS.md @@ -0,0 +1,94 @@ +# Havoc weapon system + +Source: `STR#7D02.dat` (pickup banner text) and `STR#7D03.dat` (HUD active weapon names), +extracted from `INTRFACE.FF`. True class structure and game logic to be confirmed via Ghidra +decompilation of `HAVOC.EXE`. + +Player memory: 6 weapon classes each for primary (infinite ammo) and secondary (limited ammo). + +--- + +## Primary weapons (infinite ammo) + +Weapons not appearing in the pickup table are assumed to be starting/default weapons. +Upgrades within a class are listed tier by tier. + +| Class | Tier 1 (start) | Tier 2 | Tier 3 | Tier 4 | +|---|---|---|---|---| +| Laser | Laser | Improved Laser | ElectroLaser | High-Power ElectroLaser | +| Disc | Disc | Improved Disc | Disc Gun Doubler | High-Power Disc / Fin-Stabilized Disc | +| Shockwave | Shockwave | Radial Shockwave | | | +| Fireball | Fireball | High-Velocity Fireball | | | +| Plasma | Plasma | High-Energy Plasma | | | +| VARG | VARG Omicron | VARG Omicron High-Power | VARG Omicron Focused | VARG Photon / VARG Photon High-Energy | + +Notes: +- "Laser Doubler" pickup grants a second laser barrel (doubles fire rate/output, not a separate class). +- "Disc" and "Improved Disc" do not appear as pickups -- probably starting weapons. +- "Plasma" does not appear as a pickup -- probably starting or granted via progression. +- VARG (Variable Armament Resonance Gun?) is the highest-tier class, with two sub-branches (Omicron / Photon). + +--- + +## Secondary weapons (limited ammo) + +Cheat code `mmm` gives the best Heatseeker class weapon. Cheat `aaa` = max ammo for all secondaries. + +| Class | Base weapon | Upgrade(s) | Ammo pickup | +|---|---|---|---| +| Rocket | Rocket | Rocket Repeater, Rocket Doubler | Rocket Ammo +5 / +10 | +| Cruise Missile | Cruise Missile | Afterburner Cruise | Cruise Missile Ammo +5 | +| Mortar | Mortar | High-Impact Mortar | Mortar Ammo +5 | +| Mine | Mine Dropper | Thermite Mine Dropper, Proximity Mine Dropper | Mines +5 | +| Heatseeker | Heatseeker | Afterburner Heatseeker | Heatseeker Ammo +5 | +| EMP | EMP Cannon | Improved EMP Cannon | EMP Cannon Ammo +5 | +| Fusion | Fusion Cannon | Catalyst-Aug Fusion Cannon | Fusion Cannon Ammo +5 | +| LGM | Laser Guided Missile | Thermal LGM, ElectroCharge LGM | LGM Ammo +5 | +| Warhead | Warhead | Multi-Warhead | Warhead Ammo +5 | +| Sweep Dropper | Sweep Dropper - Laser | Sweep Dropper - Photon, Sweep Dropper - Flame | Sweep Dropper Ammo +5 | +| Terraformer | Terraformer | Enhanced Terraformer | Terraformer Ammo +5 | +| Phase Missile | Phase Missile | Dual-Phase Missile | Phase Missile Ammo +5 | +| Catapult | Ion Catapult | Meson Catapult | Catapult Ammo +5 | +| Disruptor | Disruptor Cannon | High-Phase Disruptor Cannon | Disruptor Cannon Ammo +5 | + +Note: The data shows 14 distinct secondary weapon lines, but player memory is 6 classes. +Likely some of these are grouped into the same class slot in the UI (e.g. Rocket + Cruise Missile +may share one slot, LGM + Phase Missile another). Ghidra will resolve the actual slot count. + +Unaccounted-in-pickups (appear only in HUD list, may be starting secondaries): +- `Pulse` +- `Frequency-Modulated Pulse` +- `Particle Accelerator` +- `High-Velocity Particle` + +--- + +## Special pickups (non-weapon) + +| Item | Effect | +|---|---| +| Gate Key | Required to open the exit gate and complete the level | +| Shields +5 / +10 / +20 / +30 / +50 / +100 | Restores shield hitpoints | +| Shields Restored | Full shield restore | +| Ultrashield | Temporary invulnerability (or large instant shield restore) | +| Shield Max Increased +10 | Permanently raises shield capacity | +| Freeze Enemies | Temporarily freezes all enemies on screen | +| Invisibility | Temporary cloaking | +| Targeting Enhancement | Improves lock-on/targeting | +| Bonus Vehicle | Extra life | +| Installing Mass-Energy Weapon | Special weapon install event | +| Upgrading Mass-Energy Weapon | Upgrade event for the above | + +--- + +## Cheat codes + +All confirmed working in demo and retail: + +| Code | Effect | +|---|---| +| `mmm` | Best Heatseeker class weapon (Afterburner Heatseeker) | +| `vvv` | Bonus Vehicle / extra life | +| `sss` | Shield restore | +| `hhh` | Health restore | +| `aaa` | Max ammo for all secondaries | diff --git a/run_and_log.bat b/run_and_log.bat new file mode 100644 index 0000000..037c999 --- /dev/null +++ b/run_and_log.bat @@ -0,0 +1,4 @@ +@echo off +cd /d Z:\Development\devl\Havoc +HAVOC_NOCD.EXE +echo %ERRORLEVEL% > exit_code.txt diff --git a/run_probe.bat b/run_probe.bat new file mode 100644 index 0000000..f52a88a --- /dev/null +++ b/run_probe.bat @@ -0,0 +1,6 @@ +@echo off +cd /d Z:\Development\devl\Havoc +echo cwd=%CD% > probe.txt +.\HAVOC_NOCD.EXE +echo exitcode=%ERRORLEVEL% >> probe.txt +echo done >> probe.txt diff --git a/tools/analyze2.py b/tools/analyze2.py new file mode 100644 index 0000000..1a7dfda --- /dev/null +++ b/tools/analyze2.py @@ -0,0 +1,49 @@ +import struct, sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +data = open('HAVOC_NOCD.EXE','rb').read() +FO2VA = lambda fo: fo + 0x400C00 +VA2FO = lambda va: va - 0x400C00 + +def show(start, length): + for row in range(start, start + length, 16): + chunk = data[row:row+16] + ann = [] + for j in range(len(chunk)): + f = row + j + try: + if data[f] == 0xe8 and f+5 <= len(data): + rel = struct.unpack_from(' len(data): continue + val = struct.unpack_from(' 0 and data[fo-1] != 0xCC: + fo -= 1 +print("Function start: FO 0x%05x VA 0x%07x" % (fo, FO2VA(fo))) +show(fo, min(32, 0x17A60 - fo + 32)) diff --git a/tools/check_files.py b/tools/check_files.py new file mode 100644 index 0000000..90ef1a0 --- /dev/null +++ b/tools/check_files.py @@ -0,0 +1,69 @@ +"""Check what files the game needs and whether they exist, then show more code context.""" +import re, struct, os + +print("=== Files present in game directory ===") +game_dir = "." +for f in sorted(os.listdir(game_dir)): + full = os.path.join(game_dir, f) + if os.path.isfile(full): + print(f" {f:30s} {os.path.getsize(full):>10d} bytes") + elif os.path.isdir(full): + print(f" {f}/ [dir]") + +print("\n=== Checking expected files ===") +needed = ['MUSIC.FF','INTRFACE.FF','BIGFILE.DAT','STRDATA.DAT','HAVOC.INI','WORLDS'] +for n in needed: + exists = os.path.exists(n) + info = "" + if exists: + if os.path.isdir(n): + contents = os.listdir(n) + info = f" [dir, {len(contents)} files]" + else: + info = f" [{os.path.getsize(n)} bytes]" + print(f" {'OK' if exists else 'MISSING':<6} {n}{info}") + +exe = open("HAVOC_NOCD.EXE", "rb").read() +pe_off = struct.unpack_from(' fo=0x{target_fo:06x} (VA=0x{target_va:08x}): ...{exe[fo-3:fo+6].hex()}...") + +# Also look for ALL the path strings still in the binary +print("\n=== All path/filename strings in data section (0x069400-0x06EC00) ===") +data_sec = exe[0x069400:0x06ec00] +for m in re.finditer(rb'[A-Za-z0-9][A-Za-z0-9_. \\:]{2,39}(?=\x00)', data_sec): + s = m.group().decode('latin1', errors='replace') + if any(c in s for c in ['.', '\\', ':']): + fo = 0x069400 + m.start() + print(f" 0x{fo:06x}: {s!r}") diff --git a/tools/check_strings.py b/tools/check_strings.py new file mode 100644 index 0000000..2aa197d --- /dev/null +++ b/tools/check_strings.py @@ -0,0 +1,45 @@ +import struct, sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +data = open('HAVOC_NOCD.EXE','rb').read() +FO2VA = lambda fo: fo + 0x400C00 +VA2FO = lambda va: va - 0x400C00 + +# Show context around the "missing from the HAVOC directory" string +fo = 0x6bec5 +print("=== Context around FO 0x%05x ===" % fo) +start = fo - 0x80 +end = fo + 0x80 +chunk = data[start:end] +for i in range(0, len(chunk), 16): + row = chunk[i:i+16] + hex_part = ' '.join('%02x' % b for b in row) + asc_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in row) + print("FO 0x%05x: %-47s %s" % (start+i, hex_part, asc_part)) + +print() +print("=== Callers of 'missing' string at FO 0x6BEC5 VA 0x46CAC5 ===") +target = 0x46cac5 +for fo2 in range(0x400, 0x67000): + if data[fo2] == 0x68: # PUSH imm32 + imm = struct.unpack_from('= len(exe): + return None + try: + end = exe.index(b'\x00', fo) + return exe[fo:end].decode('latin1', errors='replace') + except: + return None + +def find_call_sites(iat_va): + needle = b'\xff\x15' + struct.pack('= 2: + pushes.append((ctx_start + pos, s)) + pos += 5 + else: + pos += 1 + return pushes + +print("=== CreateFileA call sites ===") +for cfo in find_call_sites(iat['CreateFileA']): + pushes = extract_string_pushes(cfo) + print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}") + +print("\n=== DeleteFileA call sites ===") +for cfo in find_call_sites(iat['DeleteFileA']): + pushes = extract_string_pushes(cfo) + print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}") + +print("\n=== GetModuleFileNameA call sites ===") +for cfo in find_call_sites(iat['GetModuleFileNameA']): + ctx = exe[cfo:cfo+80] + print(f" CALL @ 0x{cfo:06x}: {ctx.hex(' ')}") + +print("\n=== GetPrivateProfileIntA call sites ===") +for cfo in find_call_sites(iat['GetPrivateProfileIntA']): + pushes = extract_string_pushes(cfo) + print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}") + +print("\n=== WritePrivateProfileStringA call sites ===") +for cfo in find_call_sites(iat['WritePrivateProfileStringA']): + pushes = extract_string_pushes(cfo) + print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}") diff --git a/tools/dbg_path.py b/tools/dbg_path.py new file mode 100644 index 0000000..13a9c0c --- /dev/null +++ b/tools/dbg_path.py @@ -0,0 +1,137 @@ +import ctypes, sys, io +from ctypes import wintypes, byref, sizeof, c_char +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +k = ctypes.windll.kernel32 + +DEBUG_ONLY_THIS_PROCESS = 0x00000002 +DBG_CONTINUE = 0x00010002 +DBG_EXCEPTION_NOT_HANDLED = 0x80010001 +EXCEPTION_BREAKPOINT = 0x80000003 +EXCEPTION_ACCESS_VIOLATION = 0xC0000005 +CREATE_PROCESS_DEBUG_EVENT = 3 +EXCEPTION_DEBUG_EVENT = 1 +LOAD_DLL_DEBUG_EVENT = 6 +CREATE_THREAD_DEBUG_EVENT = 2 +CONTEXT_FULL = 0x00010007 # WOW64 x86 CONTEXT_FULL (CONTROL|INTEGER|SEGMENTS) + +BP_OPEN = 0x00439380 # stream open method entry (thiscall, ecx=this, path at [this+4]) + +class STARTUPINFO(ctypes.Structure): + _fields_=[("cb",wintypes.DWORD),("lpReserved",wintypes.LPWSTR),("lpDesktop",wintypes.LPWSTR), + ("lpTitle",wintypes.LPWSTR),("dwX",wintypes.DWORD),("dwY",wintypes.DWORD), + ("dwXSize",wintypes.DWORD),("dwYSize",wintypes.DWORD),("dwXCountChars",wintypes.DWORD), + ("dwYCountChars",wintypes.DWORD),("dwFillAttribute",wintypes.DWORD),("dwFlags",wintypes.DWORD), + ("wShowWindow",wintypes.WORD),("cbReserved2",wintypes.WORD),("lpReserved2",ctypes.c_void_p), + ("hStdInput",wintypes.HANDLE),("hStdOutput",wintypes.HANDLE),("hStdError",wintypes.HANDLE)] +class PROCESS_INFORMATION(ctypes.Structure): + _fields_=[("hProcess",wintypes.HANDLE),("hThread",wintypes.HANDLE), + ("dwProcessId",wintypes.DWORD),("dwThreadId",wintypes.DWORD)] +class EXCEPTION_RECORD(ctypes.Structure): + _fields_=[("ExceptionCode",wintypes.DWORD),("ExceptionFlags",wintypes.DWORD), + ("ExceptionRecord",ctypes.c_void_p),("ExceptionAddress",ctypes.c_void_p), + ("NumberParameters",wintypes.DWORD),("ExceptionInformation",ctypes.c_void_p*15)] +class EXCEPTION_DEBUG_INFO(ctypes.Structure): + _fields_=[("ExceptionRecord",EXCEPTION_RECORD),("dwFirstChance",wintypes.DWORD)] +class DEBUG_EVENT(ctypes.Structure): + class _U(ctypes.Union): + _fields_=[("Exception",EXCEPTION_DEBUG_INFO),("raw",c_char*160)] + _fields_=[("dwDebugEventCode",wintypes.DWORD),("dwProcessId",wintypes.DWORD), + ("dwThreadId",wintypes.DWORD),("u",_U)] + +# WOW64_CONTEXT x86 +class WOW64_FLOATING_SAVE_AREA(ctypes.Structure): + _fields_=[("ControlWord",wintypes.DWORD),("StatusWord",wintypes.DWORD),("TagWord",wintypes.DWORD), + ("ErrorOffset",wintypes.DWORD),("ErrorSelector",wintypes.DWORD),("DataOffset",wintypes.DWORD), + ("DataSelector",wintypes.DWORD),("RegisterArea",c_char*80),("Cr0NpxState",wintypes.DWORD)] +class WOW64_CONTEXT(ctypes.Structure): + _fields_=[("ContextFlags",wintypes.DWORD),("Dr0",wintypes.DWORD),("Dr1",wintypes.DWORD), + ("Dr2",wintypes.DWORD),("Dr3",wintypes.DWORD),("Dr6",wintypes.DWORD),("Dr7",wintypes.DWORD), + ("FloatSave",WOW64_FLOATING_SAVE_AREA),("SegGs",wintypes.DWORD),("SegFs",wintypes.DWORD), + ("SegEs",wintypes.DWORD),("SegDs",wintypes.DWORD),("Edi",wintypes.DWORD),("Esi",wintypes.DWORD), + ("Ebx",wintypes.DWORD),("Edx",wintypes.DWORD),("Ecx",wintypes.DWORD),("Eax",wintypes.DWORD), + ("Ebp",wintypes.DWORD),("Eip",wintypes.DWORD),("SegCs",wintypes.DWORD),("EFlags",wintypes.DWORD), + ("Esp",wintypes.DWORD),("SegSs",wintypes.DWORD),("ExtendedRegisters",c_char*512)] + +app = b"Z:\\Development\\devl\\Havoc\\HAVOC_NOCD.EXE" +cwd = b"Z:\\Development\\devl\\Havoc" +si=STARTUPINFO(); si.cb=sizeof(si) +pi=PROCESS_INFORMATION() +ok=k.CreateProcessA(app, None, None, None, False, DEBUG_ONLY_THIS_PROCESS, None, cwd, byref(si), byref(pi)) +if not ok: + print("CreateProcess failed", k.GetLastError()); sys.exit(1) +print("launched pid", pi.dwProcessId) +hProc=None; orig=None; threads={} +def rpm(addr,n): + buf=(c_char*n)(); rd=ctypes.c_size_t(0) + k.ReadProcessMemory(hProc, ctypes.c_void_p(addr), buf, n, byref(rd)) + return bytes(buf[:rd.value]) +def wpm(addr,data): + wr=ctypes.c_size_t(0) + return k.WriteProcessMemory(hProc, ctypes.c_void_p(addr), data, len(data), byref(wr)) + +EXIT_PROCESS_DEBUG_EVENT=5; OUTPUT_DEBUG_STRING_EVENT=8 +import time +evt=DEBUG_EVENT(); hit=0; done=False; dllcount=0 +t0=time.time() +while not done: + if time.time()-t0 > 30: + print("=== 30s budget: dumping thread states ===") + for tid,hThr in threads.items(): + ctx=WOW64_CONTEXT(); ctx.ContextFlags=CONTEXT_FULL + if k.Wow64GetThreadContext(hThr, byref(ctx)): + b=rpm(ctx.Eip,8) + print(" tid %d EIP=0x%08X bytes=%s"%(tid, ctx.Eip, b.hex())) + k.TerminateProcess(pi.hProcess,0); break + if not k.WaitForDebugEvent(byref(evt), 3000): + continue + code=evt.dwDebugEventCode; cont=DBG_CONTINUE + if code==CREATE_PROCESS_DEBUG_EVENT: + hProc=pi.hProcess + threads[evt.dwThreadId]=pi.hThread + orig=rpm(BP_OPEN,1) + okw=wpm(BP_OPEN, b"\xCC") + chk=rpm(BP_OPEN,1) + print("set BP at 0x%X (orig=%s wpm_ok=%s now=%s)"%(BP_OPEN, orig.hex(), bool(okw), chk.hex())) + elif code==EXIT_PROCESS_DEBUG_EVENT: + print("PROCESS EXITED code=0x%X"%(evt.u.Exception.ExceptionRecord.ExceptionCode)); done=True + elif code==LOAD_DLL_DEBUG_EVENT: + dllcount+=1 + elif code==CREATE_THREAD_DEBUG_EVENT: + # track new thread handle: reopen by tid + h=k.OpenThread(0x1FFFFF, False, evt.dwThreadId) + if h: threads[evt.dwThreadId]=h + elif code==EXCEPTION_DEBUG_EVENT: + er=evt.u.Exception.ExceptionRecord + exc=er.ExceptionCode & 0xffffffff + addr=er.ExceptionAddress or 0 + if exc==EXCEPTION_BREAKPOINT and addr==BP_OPEN: + hThr=threads.get(evt.dwThreadId) + ctx=WOW64_CONTEXT(); ctx.ContextFlags=CONTEXT_FULL + k.Wow64GetThreadContext(hThr, byref(ctx)) + this=ctx.Ecx + pathptr=this+4 + raw=rpm(pathptr,160) + s=raw.split(b"\x00")[0] + hit+=1 + print("BP hit #%d: this=0x%08X path@0x%08X = %r"%(hit,this,pathptr,s.decode('latin1'))) + # restore and step back so it re-executes original + wpm(BP_OPEN, orig) + ctx.Eip=BP_OPEN + ctx.ContextFlags=CONTEXT_FULL + k.Wow64SetThreadContext(hThr, byref(ctx)) + if hit>=3: + print("captured enough; terminating child") + k.TerminateProcess(hProc,0); done=True + elif exc==EXCEPTION_ACCESS_VIOLATION: + hThr=threads.get(evt.dwThreadId) + ctx=WOW64_CONTEXT(); ctx.ContextFlags=CONTEXT_FULL + k.Wow64GetThreadContext(hThr, byref(ctx)) + print("ACCESS_VIOLATION eip=0x%08X eax=0x%08X esi=0x%08X ecx=0x%08X (firstchance=%d)"%( + ctx.Eip, ctx.Eax, ctx.Esi, ctx.Ecx, evt.u.Exception.dwFirstChance)) + k.TerminateProcess(hProc,0); done=True + elif exc==EXCEPTION_BREAKPOINT: + pass # initial ntdll bp + else: + cont=DBG_EXCEPTION_NOT_HANDLED + k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, cont) +print("debugger done") diff --git a/tools/deep_audit.py b/tools/deep_audit.py new file mode 100644 index 0000000..ba72188 --- /dev/null +++ b/tools/deep_audit.py @@ -0,0 +1,103 @@ +import re, struct, os + +exe = open("HAVOC_NOCD.EXE", "rb").read() + +# ── 1. Verify raw bytes at known path locations ── +print("=== Raw bytes at path string locations ===") +spots = [ + (0x06b27c, 16, "MUSIC.FF area"), + (0x06b288, 16, "INTRFACE.FF area"), + (0x06b2b0, 24, "WORLDS\\ area"), + (0x06b390, 30, "C:\\WORLDS\\LAND0101 area"), + (0x06b3a8, 20, "HAVOCDAT area"), + (0x06b3b8, 16, "BIGFILE area"), + (0x06b978, 24, "GAMIMAGE area"), +] +for fo, length, label in spots: + raw = exe[fo:fo+length] + asc = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in raw) + print(f" 0x{fo:06x} {label}: {raw.hex(' ')}") + print(f" ascii: {asc!r}") + +# ── 2. Check which expected files exist ── +print("\n=== File existence check in game dir ===") +game_files = [ + "BIGFILE.DAT", "MUSIC.FF", "INTRFACE.FF", "OBJECTS.FF", "SOUND.FF", + "STRDATA.DAT", "HAVOC.INI", "GAMIMAGE.DAT", "KEYSET.DAT", + "WORLDS/LAND0101.DAT", "WORLDS/GRAFIX00.DAT", +] +for f in game_files: + exists = os.path.exists(f) + print(f" {'OK' if exists else 'MISSING':8s} {f}") + +# ── 3. Find SetCurrentDirectory / GetModuleFileName in imports ── +print("\n=== SetCurrentDirectory / GetModuleFileName call sites ===") +for api_bytes in [b'SetCurrentDirectoryA', b'GetModuleFileNameA', b'_chdir']: + idx = exe.find(api_bytes) + if idx == -1: + print(f" NOT IN EXE: {api_bytes.decode()}") + continue + print(f" {api_bytes.decode()} name at 0x{idx:06x}") + # find IAT VA for this + hint_fo = idx - 2 + int_rva = hint_fo - 0x000400 + 0x001000 + iat_needle = struct.pack(' VA=0x{music_va:08x}") + +# Find MessageBoxA IAT entry +print("\n=== MessageBoxA IAT ===") +mbox_name_off = exe.find(b'MessageBoxA\x00') +print(f" name at 0x{mbox_name_off:06x}") +mbox_iat_fo = None +for i in range(0x06ec00, 0x06fa00, 4): + thunk_va = struct.unpack_from(' no VA") + return + needle = struct.pack('0x{tfo:06x}' + print(f" {fo:06x}: {h:<48} {a}{ann}") + +# Context around CALL at 0x020253 -> 0x009C60 +print("\n=== Context before error check (0x020230-0x020285) ===") +for row in range(0, 0x60, 16): + fo = 0x020230 + row + h = ' '.join(f'{b:02x}' for b in exe[fo:fo+16]) + a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[fo:fo+16]) + ann = '' + if exe[fo] == 0xe8: + rel = struct.unpack_from('0x{tfo:06x}' + print(f" {fo:06x}: {h:<48} {a}{ann}") + +# Check the flag check, and suggest bypass bytes +print("\n=== Error gate at 0x020270 (current bytes) ===") +ctx = exe[0x020270:0x020282] +print(f" {ctx.hex(' ')}") +print(f" Byte at 0x020277: 0x{ctx[7]:02x} (should be 75=JNZ for unfixed, or eb=JMP for bypass)") + +# Check if there's a HAVOC.INI read code that stores the drive to 0x06b2d4 +drive_va = fo_to_va(0x06b2d4) +print(f"\n=== All writes to D:\\ buffer area VA=0x{drive_va:08x} ===") +# Look for MOV BYTE/DWORD PTR [0x47DED4] patterns +# Could be: a2, c6 05, a3, c7 05 ... +# Also look for LEA/MOV reg, addr then strcpy/memcpy diff --git a/tools/dump_region.py b/tools/dump_region.py new file mode 100644 index 0000000..5dcf0b9 --- /dev/null +++ b/tools/dump_region.py @@ -0,0 +1,109 @@ +import re, struct + +exe = open("HAVOC_NOCD.EXE", "rb").read() +pe_off = struct.unpack_from(' + needle = b'\xff\x15' + struct.pack(' [0x%08x]" % (fo, FO2VA(fo), iat_addr)) + print(" Context: %s" % context_str[-60:]) + +print() +# Better approach: look for the function that opens the INI file +# GetPrivateProfileString calls should push the INI filename as last arg +# HAVOC.INI = b'HAVOC.INI' +havoc_ini_fo = data.find(b'HAVOC.INI\x00') +if havoc_ini_fo == -1: + havoc_ini_fo = data.find(b'HAVOC.ini\x00') +print("HAVOC.INI string at FO 0x%05x VA 0x%08x" % (havoc_ini_fo, FO2VA(havoc_ini_fo))) + +# Now find all pushes of HAVOC.INI's VA +ini_va = FO2VA(havoc_ini_fo) +print("=== Code pushing HAVOC.INI VA 0x%08x ===" % ini_va) +for fo in range(0x400, 0x67000): + if data[fo] == 0x68: + imm = struct.unpack_from(' 50: + continue + seen.add(s) + print(f" 0x{fo:06x}: {s!r}") + +# ── 2. Find the IAT VA for every imported function ── +# Scan the import descriptor table to map function name -> IAT file offset +print("\n=== Imports (name -> IAT file offset -> VA) ===") +iat_map = {} # name -> iat_va + +# The IAT in HAVOC.EXE is around 0x06ec00 based on prior analysis. +# Scan 0x06ec00..0x06fd00 for hint+name entries and build a name->IAT map. +# Actually easier: find all FF 15 (CALL [mem]) targets in .text, then resolve names. +call_indirect = {} # iat_va -> [call_fo, ...] +for m in re.finditer(b'\xff\x15', exe[0x400:0x067000]): + call_fo = 0x400 + m.start() + iat_va = struct.unpack_from(' iat_va +for api in apis_of_interest: + idx = exe.find(api) + while idx != -1: + # the hint is 2 bytes before the name; the IAT entry points here via the INT + # Find which IAT entry references this (INT entry = &hint+name) + hint_fo = idx - 2 + int_rva = 0x400000 + hint_fo - 0x000400 + 0x001000 # VA of hint record + needle = struct.pack('= len(exe): + return f"" + try: + end = exe.index(b'\x00', fo) + return exe[fo:end].decode('latin1', errors='replace') + except: + return f"" + +print("\n=== GetPrivateProfileStringA parameters at each call site ===") +for cfo in sorted(call_sites): + # Scan backward up to 128 bytes for PUSH imm32 (0x68) instructions + ctx_start = max(0x400, cfo - 128) + ctx = exe[ctx_start:cfo] + pushes = [] # list of (offset_in_ctx, value) + pos = 0 + while pos < len(ctx): + b = ctx[pos] + if b == 0x68: # PUSH imm32 + if pos + 5 <= len(ctx): + val = struct.unpack_from(' in .text (file load refs) ──") +# Rdata VA range: roughly 0x400000 + (0x067000 - 0x400 + 0x1000) = 0x400000 + 0x067c00 = 0x467c00 +# to end of file +rdata_va_start = 0x400000 + 0x067000 - 0x000400 + 0x001000 +rdata_va_end = 0x400000 + len(exe) - 0x000400 + 0x001000 +interesting_strings = {} +# Build map of rdata VA -> string +rdata = exe[0x067000:] +for m in re.finditer(rb'[A-Za-z][A-Za-z0-9_.\\]{3,30}\x00', rdata): + s = m.group()[:-1].decode('latin1') + if '.' in s or '\\' in s: + va = rdata_va_start - 0x067000 + 0x067000 + m.start() + # recalc: rdata file offset = 0x067000 + m.start() + fo = 0x067000 + m.start() + va = 0x400000 + fo - 0x000400 + 0x001000 + interesting_strings[va] = s + +# Now scan .text for pushes of these VAs +for m in re.finditer(b'\x68', exe[0x400:0x067000]): + call_fo = 0x400 + m.start() + if call_fo + 5 > 0x067000: + continue + va = struct.unpack_from(' iat_va + +while True: + orig_thunk_rva = struct.unpack_from(' %s" % (iat_va, fn_str)) + thunk_fo += 4 + thunk_idx += 1 + fo += 20 diff --git a/tools/patch_exappwindow.py b/tools/patch_exappwindow.py new file mode 100644 index 0000000..cb1f0d2 --- /dev/null +++ b/tools/patch_exappwindow.py @@ -0,0 +1,114 @@ +import struct, sys + +# Set TEST_MODE=True to use dwExStyle=0 (original), False for WS_EX_APPWINDOW +TEST_MODE = True + +exe = bytearray(open("HAVOC_NOCD.EXE", "rb").read()) +BASE = 0x400000 + +# ---- Locate .text section header ------------------------------------------- +e_lfanew = struct.unpack_from(' IP after jmp = 0x{cave_va+CAVE_SIZE:07x}, " + f"target = 0x{cave_va+CAVE_SIZE+jmp_back:07x}") + +# ---- Patch (12 bytes at 0x4260c7) ----------------------------------------- +jmp_cave = cave_va - (PATCH_VA + 1 + 5) +patch = bytearray() +patch += b'\x51' # push ecx (title) (1b) +patch += b'\xE9' + struct.pack(' IP after jmp = 0x{PATCH_VA+6:07x}, " + f"target = 0x{PATCH_VA+6+jmp_cave:07x}") + +# Confirm jump targets +assert PATCH_VA + 6 + jmp_cave == cave_va, "jmp_cave target mismatch" +assert cave_va + CAVE_SIZE + jmp_back == BACK_VA, "jmp_back target mismatch" +print("Jump target assertions: OK") + +# ---- Apply ----------------------------------------------------------------- +new_vsiz = TEXT_RAWSIZ # expose all raw data as executable +struct.pack_into(' 0x{new_vsiz:x}") + +exe[cave_fo:cave_fo+CAVE_SIZE] = cave_code +exe[patch_fo:patch_fo+12] = patch + +open("HAVOC_NOCD.EXE", "wb").write(exe) +print("Patch R applied.") diff --git a/tools/patch_flagcheck.py b/tools/patch_flagcheck.py new file mode 100644 index 0000000..192e3fc --- /dev/null +++ b/tools/patch_flagcheck.py @@ -0,0 +1,36 @@ +"""Patch J: bypass the init-flag error gate at 0x020277. + +The instruction at 0x020277 is JNZ +0x2e (75 2e). When the init flag at +VA 0x475425 is zero the fall-through path shows the 'corrupt files' error. +The jump target (0x0202a7) is the success return stub: + b0 01 MOV AL, 1 + 5d 5f 5e 5b POP EBP/EDI/ESI/EBX + 83 c4 44 ADD ESP, 0x44 + c2 0c 00 RET 0x0c + +Changing 75->eb turns JNZ into JMP so execution always takes the success path. +""" +import struct + +with open("HAVOC_NOCD.EXE", "rb") as f: + exe = bytearray(f.read()) + +PATCH_FO = 0x020277 +# Verify expected bytes +expected = bytes([0x75, 0x2e]) +actual = bytes(exe[PATCH_FO:PATCH_FO+2]) +if actual != expected: + print(f"ERROR: expected {expected.hex(' ')} at 0x{PATCH_FO:06x}, got {actual.hex(' ')}") + raise SystemExit(1) + +exe[PATCH_FO] = 0xeb # JNZ -> JMP + +# Verify target bytes at 0x0202a7 +target_bytes = bytes(exe[0x0202a7:0x0202a7+12]) +print(f"Patch J target bytes at 0x0202a7: {target_bytes.hex(' ')}") +# should be: b0 01 5d 5f 5e 5b 83 c4 44 c2 0c 00 + +with open("HAVOC_NOCD.EXE", "wb") as f: + f.write(exe) + +print(f"Patch J applied: 0x{PATCH_FO:06x}: 75 2e -> eb 2e (JNZ -> JMP, bypass init-flag check)") diff --git a/tools/patch_paths.py b/tools/patch_paths.py new file mode 100644 index 0000000..6853d41 --- /dev/null +++ b/tools/patch_paths.py @@ -0,0 +1,51 @@ +"""Patch hardcoded C:\ path prefixes out of HAVOC_NOCD.EXE so it loads files +from the current directory instead of C:\ root.""" + +exe = bytearray(open("HAVOC_NOCD.EXE", "rb").read()) + +# Each tuple: (bytes_to_find, bytes_to_replace, description) +# Lengths must match -- we strip "C:\" (3 bytes) and pad with nulls at the end. +patches = [ + # C:\MUSIC.FF\0 (12 bytes) -> MUSIC.FF\0 + 3 nulls + ( + bytes([0x43,0x3a,0x5c,0x4d,0x55,0x53,0x49,0x43,0x2e,0x46,0x46,0x00]), + bytes([ 0x4d,0x55,0x53,0x49,0x43,0x2e,0x46,0x46,0x00,0x00,0x00,0x00]), + "C:\\MUSIC.FF -> MUSIC.FF", + ), + # C:\INTRFACE.FF\0\0 (16 bytes) -> INTRFACE.FF\0 + 4 nulls + ( + bytes([0x43,0x3a,0x5c,0x49,0x4e,0x54,0x52,0x46,0x41,0x43,0x45,0x2e,0x46,0x46,0x00,0x00]), + bytes([ 0x49,0x4e,0x54,0x52,0x46,0x41,0x43,0x45,0x2e,0x46,0x46,0x00,0x00,0x00,0x00,0x00]), + "C:\\INTRFACE.FF -> INTRFACE.FF", + ), + # C:\WORLDS\ \0\0 (12 bytes) -> WORLDS\ \0 + 4 nulls + ( + bytes([0x43,0x3a,0x5c,0x57,0x4f,0x52,0x4c,0x44,0x53,0x5c,0x00,0x00]), + bytes([ 0x57,0x4f,0x52,0x4c,0x44,0x53,0x5c,0x00,0x00,0x00,0x00,0x00]), + "C:\\WORLDS\\ -> WORLDS\\", + ), + # C:\BIGFILE.DAT\0\0 (16 bytes) -> BIGFILE.DAT\0 + 4 nulls + ( + bytes([0x43,0x3a,0x5c,0x42,0x49,0x47,0x46,0x49,0x4c,0x45,0x2e,0x44,0x41,0x54,0x00,0x00]), + bytes([ 0x42,0x49,0x47,0x46,0x49,0x4c,0x45,0x2e,0x44,0x41,0x54,0x00,0x00,0x00,0x00,0x00]), + "C:\\BIGFILE.DAT -> BIGFILE.DAT", + ), +] + +for search, replace, desc in patches: + assert len(search) == len(replace), f"Length mismatch for {desc}: {len(search)} vs {len(replace)}" + idx = bytes(exe).find(search) + if idx == -1: + print(f" NOT FOUND (check bytes): {desc}") + # print surrounding bytes for debugging + # try to find just the ascii part + ascii_part = bytes(b for b in search if 0x20 <= b < 0x7f) + idx2 = bytes(exe).find(ascii_part) + if idx2 != -1: + print(f" ASCII match at 0x{idx2:06x}: {bytes(exe[idx2-4:idx2+len(ascii_part)+4]).hex(' ')}") + continue + exe[idx:idx+len(replace)] = replace + print(f" OK 0x{idx:06x}: {desc}") + +open("HAVOC_NOCD.EXE", "wb").write(exe) +print("\nDone. HAVOC_NOCD.EXE no longer needs C:\\ symlinks for file loading.") diff --git a/tools/patch_vtable.py b/tools/patch_vtable.py new file mode 100644 index 0000000..423f338 --- /dev/null +++ b/tools/patch_vtable.py @@ -0,0 +1,112 @@ +""" +Patch V: vtable-setter in init_archive (0x443010) + +Root cause: init_archive explicitly clears vtable ([ESI+0]=0) and never sets it. +load_archives later calls 0x443140 (cleanup) for each archive object; with vtable=0, +0x443140 unmaps the file, closes handles, and zeroes field_0xC (directory ptr). +open_archive_file checks field_0xC != 0, so it fails, and get_archive returns 0 => +"corrupt or unavailable game file" error fires. + +Fix: insert MOV [ESI+0], 1 at the init_archive success return path. With vtable=1, +0x443140's vtable-check (TEST EAX; JNZ) skips cleanup, field_0xC is preserved, and +get_archive succeeds. + +Mechanism: + FO 0x42514 (VA 0x443114): replace 5-byte MOV EAX,1 with JMP to cave at FO 0x66FCE + Cave (FO 0x66FCE, VA 0x467BCE): + c7 46 00 01 00 00 00 MOV [ESI+0], 1 <- set vtable sentinel + b8 01 00 00 00 MOV EAX, 1 <- return value = success + 5e 5b c2 08 00 POP ESI; POP EBX; RET 8 +""" +import struct, sys + +EXE = "HAVOC_NOCD.EXE" +exe = bytearray(open(EXE, "rb").read()) +BASE = 0x400000 +FO2VA = lambda fo: fo + 0x400C00 + +# ---- locate .text section header ---- +e_lfanew = struct.unpack_from(' 0x%07x (cave=0x%07x)" % (tgt, patch_v_cave_va)) + if tgt == patch_v_cave_va: + print(" -> points to our cave; verifying cave code...") + cave_expected = bytes.fromhex('c746000100000' + '0b801000000' + '5e5bc20800') + cave_actual = bytes(exe[patch_v_cave_fo:patch_v_cave_fo+17]) + if cave_actual == cave_expected: + print(" Cave code matches. Patch already applied.") + sys.exit(0) + print(" -> unexpected; re-applying") +else: + print("ERROR: unexpected bytes at patch site: %s" % actual_at_site.hex(' ')) + sys.exit(1) + +# ---- Build cave code ---- +cave_code = bytearray() +cave_code += b'\xc7\x46\x00\x01\x00\x00\x00' # MOV [ESI+0], 1 (7 bytes) +cave_code += b'\xb8\x01\x00\x00\x00' # MOV EAX, 1 (5 bytes) +cave_code += b'\x5e\x5b\xc2\x08\x00' # POP ESI; POP EBX; RET 8 (5 bytes) +assert len(cave_code) == 17, "cave size %d != 17" % len(cave_code) +print("Cave code (%d bytes): %s" % (len(cave_code), cave_code.hex(' '))) + +# ---- Build JMP at patch site ---- +next_instr_va = FO2VA(PATCH_FO + 5) +rel32 = (patch_v_cave_va - next_instr_va) & 0xFFFFFFFF +jmp_bytes = b'\xe9' + struct.pack(' cave 0x%07x)" % ( + jmp_bytes.hex(' '), rel32, patch_v_cave_va)) +# Verify jump lands on cave +assert (next_instr_va + struct.unpack_from(' 0x%x" % (text_vsiz, new_vsiz)) + +# ---- Apply ---- +exe[patch_v_cave_fo:patch_v_cave_fo+17] = cave_code +exe[PATCH_FO:PATCH_FO+5] = jmp_bytes +struct.pack_into('10} {'VirtSize':>10} {'RawOff':>10} {'RawSize':>10}") +sections = [] +for i in range(num_sections): + s = sections_off + i * 40 + name = exe[s:s+8].rstrip(b'\x00').decode('latin1', errors='replace') + virt_rva = struct.unpack_from(' run pool init) ===") +print(r2.cmd("pd 30 @ 0x43a960")) + +# Also check what's at VA 0x429EC1 and VA 0x429F6C (other crash_stub callers in 0x429CA0 area) +print("\n=== 0x429EB3 (pool_alloc call near 0x429EC1) ===") +print(r2.cmd("pd 25 @ 0x429eb3")) + +print("\n=== 0x429F5E (pool_alloc call near 0x429F6C) ===") +print(r2.cmd("pd 25 @ 0x429f5e")) + +r2.quit() diff --git a/tools/read_bin_iso.py b/tools/read_bin_iso.py new file mode 100644 index 0000000..97c9513 --- /dev/null +++ b/tools/read_bin_iso.py @@ -0,0 +1,95 @@ +""" +Parse ISO 9660 from a MODE1/2352 raw BIN file. +Each sector = 2352 bytes; user data starts at byte 16 (after sync+header). +""" +import struct, sys, os + +BIN = sys.argv[1] if len(sys.argv) > 1 else r"Z:\Development\devl\Havoc\tmp_cd\Havoc (USA) (Game Disk).bin" +SECTOR = 2352 +DATA_OFF = 16 # within each sector: sync(12) + header(4) +DATA_LEN = 2048 + +def read_sector(f, lba): + f.seek(lba * SECTOR + DATA_OFF) + return f.read(DATA_LEN) + +def read_u16_le(b, off): return struct.unpack_from(' 0: + chunk = read_sector(f, cur_lba) + data += chunk[:min(DATA_LEN, remaining)] + remaining -= DATA_LEN + cur_lba += 1 + + off = 0 + while off < len(data): + reclen = data[off] + if reclen == 0: + off = (off + 1 + 1) & ~1 # skip padding; advance + if off >= len(data): break + continue + flags = data[off + 25] + name_len = data[off + 32] + name_raw = data[off+33:off+33+name_len] + # Strip version suffix ;1 + name = name_raw.decode('ascii', errors='replace').split(';')[0].rstrip('\x00') + file_lba = read_u32_both(data, off + 2) + file_size = read_u32_both(data, off + 10) + is_dir = bool(flags & 0x02) + if name not in ('', '\x01'): + entries.append({'name': name, 'lba': file_lba, 'size': file_size, + 'is_dir': is_dir, 'indent': indent}) + if is_dir: + parse_dir(f, file_lba, file_size, indent+1, entries) + off += reclen + return entries + +with open(BIN, 'rb') as f: + # Check for ISO 9660 PVD at sector 16 + pvd = read_sector(f, 16) + if pvd[1:6] != b'CD001': + print(f"No ISO 9660 PVD at sector 16. Got: {pvd[0:8].hex(' ')}") + # Try sector 16 with different offset (some TOAST BINs have ISO 9660 elsewhere) + # Check volume descriptor sequence starting at 16 + for lba in [16, 17, 18, 32, 64]: + s = read_sector(f, lba) + print(f" Sector {lba}: type={s[0]} sig={s[1:6]} system_id={s[8:40].rstrip()}") + sys.exit(1) + + vol_type = pvd[0] # 1 = PVD + system_id = pvd[8:40].decode('ascii', errors='replace').strip() + vol_id = pvd[40:72].decode('ascii', errors='replace').strip() + vol_space = read_u32_both(pvd, 80) + print(f"ISO 9660 PVD found:") + print(f" System ID: {system_id!r}") + print(f" Volume ID: {vol_id!r}") + print(f" Vol sectors:{vol_space}") + + root_dir_lba = read_u32_both(pvd, 156 + 2) + root_dir_size = read_u32_both(pvd, 156 + 10) + print(f" Root dir LBA={root_dir_lba} size={root_dir_size}") + print() + + entries = parse_dir(f, root_dir_lba, root_dir_size) + + print(f"Files ({len(entries)} entries):") + for e in entries: + prefix = ' ' * e['indent'] + tag = '[DIR]' if e['is_dir'] else f"{e['size']:>10}" + print(f" {prefix}{e['name']:<30} {tag}") + + print() + # Look for specific files we need + targets = ['HAVOCDAT.DAT', 'KEYSET.DAT', 'BIGFILE.DAT', 'GAMIMAGE.DAT'] + print("Target file search:") + for e in entries: + if e['name'].upper() in targets: + print(f" FOUND: {e['name']} at LBA {e['lba']} size {e['size']}") diff --git a/tools/trace_init.py b/tools/trace_init.py new file mode 100644 index 0000000..e854dc3 --- /dev/null +++ b/tools/trace_init.py @@ -0,0 +1,48 @@ +import re, struct + +exe = open("HAVOC_NOCD.EXE", "rb").read() + +pe_off = struct.unpack_from(' corrupt error' +# Instruction at 0x020270: a0 25 54 47 00 = MOV AL, [0x00475425] +flag_va = 0x00475425 +flag_needle = struct.pack('