havoc-remaster/tools/patch_paths.py
pyr0ball 27174a1c79 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\<name>" 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
2026-07-03 09:29:59 -07:00

51 lines
2.3 KiB
Python

"""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.")