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

95 lines
3.4 KiB
Python

"""
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('<H', b, off)[0]
def read_u32_le(b, off): return struct.unpack_from('<I', b, off)[0]
def read_u32_both(b, off): return struct.unpack_from('<I', b, off)[0] # LE half
def parse_dir(f, lba, size, indent=0, entries=None):
if entries is None:
entries = []
data = b''
remaining = size
cur_lba = lba
while remaining > 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']}")