havoc-remaster/tools/check_strings.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

45 lines
1.6 KiB
Python

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('<I', data, fo2+1)[0]
if imm == target:
print(" PUSH 0x46CAC5 at FO 0x%05x VA 0x%08x" % (fo2, FO2VA(fo2)))
# Also look for what calls the string display - search for any CALL near those PUSHes
print()
print("=== Reading STRDATA.DAT ===")
try:
sd = open('STRDATA.DAT','rb').read()
print("Size: %d bytes" % len(sd))
# Try to find "corrupt" in it
for needle in [b'corrupt', b'unavail', b'Corrupt', b'Unavail']:
idx = sd.find(needle)
if idx != -1:
print("Found '%s' at offset 0x%x: %r" % (needle.decode(), idx, sd[max(0,idx-20):idx+60]))
# Show first 256 bytes as text
print("First 200 bytes:")
printable = ''.join(chr(b) if 32 <= b < 127 else ('\\x%02x' % b) for b in sd[:200])
print(printable)
except Exception as e:
print("Error: %s" % e)