mirror of
https://github.com/sal063/AC6_recomp
synced 2026-08-22 15:11:33 -04:00
a353f9d7aa
Any fatal event now writes one self-contained crash-<timestamp>.txt - guest call stack, registers, a guest-versus-host verdict on the faulting address, modules and the log tail naming rex_sub_* frames with no symbols.
237 lines
9.5 KiB
Python
237 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Decode an ac6recomp crash report to exact guest instructions, offline.
|
|
|
|
The crash handler ships raw host RVAs (exe+0x...) because users don't have
|
|
the PDB. This tool runs where the PDB exists (a dev machine; the
|
|
reproducible build regenerates it byte-identical for any released commit)
|
|
and rewrites each host frame as:
|
|
|
|
exe+0x00123456 rex_sub_8209ABCD generated/ac6recomp_recomp.7.cpp:1234
|
|
guest 0x8209ABF0: lwz r11,0(r3) <- the exact guest instruction
|
|
|
|
Usage:
|
|
python tools/symbolize_crash.py <crash-....txt>
|
|
[--exe out/build/win-amd64-relwithdebinfo/ac6recomp.exe]
|
|
[--generated generated] [--force]
|
|
|
|
Requires llvm-symbolizer (and llvm-readobj for the PDB match check) on PATH
|
|
or in C:/Program Files/LLVM/bin. The PDB must MATCH the exe (same build):
|
|
the tool compares the exe's RSDS debug-directory GUID against the PDB's and
|
|
refuses to print symbols from a mismatched pair unless --force is given -
|
|
wrong symbols are worse than none.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
LLVM_DIRS = [r"C:\Program Files\LLVM\bin", "/usr/bin", "/usr/local/bin"]
|
|
|
|
|
|
def find_tool(name):
|
|
path = shutil.which(name)
|
|
if path:
|
|
return path
|
|
for d in LLVM_DIRS:
|
|
candidate = os.path.join(d, name + (".exe" if os.name == "nt" else ""))
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def pe_pdb_guid(readobj, exe):
|
|
"""The exe's RSDS GUID+age and referenced PDB path, via llvm-readobj."""
|
|
out = subprocess.run([readobj, "--coff-debug-directory", exe],
|
|
capture_output=True, text=True).stdout
|
|
guid = re.search(r"PDBGUID:\s*\(([^)]*)\)", out)
|
|
age = re.search(r"PDBAge:\s*(\d+)", out)
|
|
pdb = re.search(r"PDBFileName:\s*(.+)", out)
|
|
if not guid:
|
|
# Older llvm-readobj prints "GUID: {...}"
|
|
guid = re.search(r"GUID:\s*\{?([0-9A-Fa-f-]+)\}?", out)
|
|
return (guid.group(1).strip() if guid else None,
|
|
age.group(1) if age else None,
|
|
pdb.group(1).strip() if pdb else None)
|
|
|
|
|
|
def pdb_guid(pdbutil, pdb_path):
|
|
out = subprocess.run([pdbutil, "dump", "--summary", pdb_path],
|
|
capture_output=True, text=True).stdout
|
|
guid = re.search(r"GUID:\s*\{?([0-9A-Fa-f-]+)\}?", out)
|
|
age = re.search(r"Age:\s*(\d+)", out)
|
|
return (guid.group(1).strip() if guid else None, age.group(1) if age else None)
|
|
|
|
|
|
def normalize_guid(g):
|
|
if not g:
|
|
return None
|
|
return re.sub(r"[^0-9A-F]", "", g.upper())
|
|
|
|
|
|
def crash_build_identity(crash_path):
|
|
"""The 'Build ... commit <hash> built <stamp>' line the handler writes."""
|
|
with open(crash_path, "r", errors="replace") as f:
|
|
for _ in range(40):
|
|
line = f.readline()
|
|
if not line:
|
|
break
|
|
m = re.match(r"Build\s+(\S+)\s+commit\s+(\S+)\s+built\s+(\S+)", line.strip())
|
|
if m:
|
|
return m.group(2), m.group(3)
|
|
return None, None
|
|
|
|
|
|
def exe_contains_tokens(exe_path, tokens):
|
|
"""Are these build strings compiled into this exe? They come from
|
|
version.h, so a binary from another build carries different ones."""
|
|
try:
|
|
with open(exe_path, "rb") as f:
|
|
blob = f.read()
|
|
except OSError:
|
|
return None
|
|
return all(t.encode("ascii", "ignore") in blob for t in tokens if t)
|
|
|
|
|
|
MNEMONIC_RE = re.compile(r"^\s*//\s+(\S.*)$")
|
|
LABEL_RE = re.compile(r"^\s*loc_([0-9A-Fa-f]{8}):")
|
|
LR_RE = re.compile(r"ctx\.lr\s*=\s*0x([0-9A-Fa-f]{8})")
|
|
|
|
|
|
def guest_instruction_at(generated_root, rel_file, line_number):
|
|
"""The PPC mnemonic comment directly above `line_number`, plus the
|
|
nearest preceding guest-address anchor (loc_ label or ctx.lr store)."""
|
|
path = rel_file
|
|
if not os.path.isabs(path):
|
|
path = os.path.join(generated_root, os.path.basename(rel_file))
|
|
if not os.path.isfile(path):
|
|
return None, None
|
|
try:
|
|
with open(path, "r", errors="replace") as f:
|
|
lines = f.readlines()
|
|
except OSError:
|
|
return None, None
|
|
if line_number < 1 or line_number > len(lines):
|
|
return None, None
|
|
mnemonic = None
|
|
anchor = None
|
|
for i in range(line_number - 1, max(-1, line_number - 400), -1):
|
|
text = lines[i]
|
|
if mnemonic is None:
|
|
m = MNEMONIC_RE.match(text)
|
|
if m and not m.group(1).startswith(("=", "-", "TODO")):
|
|
mnemonic = m.group(1).strip()
|
|
if anchor is None:
|
|
m = LABEL_RE.match(text)
|
|
if m:
|
|
anchor = "after loc_%s" % m.group(1).upper()
|
|
else:
|
|
m = LR_RE.search(text)
|
|
if m:
|
|
anchor = "after call site lr=0x%s" % m.group(1).upper()
|
|
if mnemonic and anchor:
|
|
break
|
|
# A function boundary ends the walk - anything above belongs elsewhere.
|
|
if text.startswith("PPC_FUNC_IMPL("):
|
|
break
|
|
return mnemonic, anchor
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("crash", help="crash-....txt written by the game")
|
|
parser.add_argument("--exe", default="out/build/win-amd64-relwithdebinfo/ac6recomp.exe")
|
|
parser.add_argument("--pdb", default=None, help="default: next to the exe")
|
|
parser.add_argument("--generated", default="generated")
|
|
parser.add_argument("--force", action="store_true",
|
|
help="symbolize even if the PDB does not match the exe")
|
|
args = parser.parse_args()
|
|
|
|
symbolizer = find_tool("llvm-symbolizer")
|
|
if not symbolizer:
|
|
sys.exit("llvm-symbolizer not found (install LLVM or add it to PATH)")
|
|
pdb_path = args.pdb or os.path.splitext(args.exe)[0] + ".pdb"
|
|
|
|
# --- PDB<->exe match check. Wrong symbols are worse than none.
|
|
readobj = find_tool("llvm-readobj")
|
|
pdbutil = find_tool("llvm-pdbutil")
|
|
if readobj and pdbutil and os.path.isfile(pdb_path):
|
|
exe_guid, exe_age, exe_pdb_name = pe_pdb_guid(readobj, args.exe)
|
|
got_guid, got_age = pdb_guid(pdbutil, pdb_path)
|
|
if normalize_guid(exe_guid) and normalize_guid(got_guid) and \
|
|
normalize_guid(exe_guid) != normalize_guid(got_guid):
|
|
print("*" * 72)
|
|
print("PDB MISMATCH: %s does not belong to %s" % (pdb_path, args.exe))
|
|
print(" exe RSDS GUID: %s (age %s, references %s)" % (exe_guid, exe_age,
|
|
exe_pdb_name))
|
|
print(" pdb GUID: %s (age %s)" % (got_guid, got_age))
|
|
print(" Rebuild the released commit (reproducible build) to regenerate the")
|
|
print(" matching PDB. Symbols below would be WRONG.")
|
|
print("*" * 72)
|
|
if not args.force:
|
|
sys.exit(2)
|
|
else:
|
|
print("note: PDB match check skipped (llvm-readobj/llvm-pdbutil or the PDB "
|
|
"itself unavailable) - trust the symbols accordingly")
|
|
|
|
# --- Does this exe actually correspond to this crash file? The PDB check
|
|
# above only proves the PDB belongs to the exe; decoding a crash from a
|
|
# DIFFERENT build against it yields confident, wrong answers.
|
|
crash_commit, crash_built = crash_build_identity(args.crash)
|
|
if crash_commit or crash_built:
|
|
present = exe_contains_tokens(args.exe, [crash_commit, crash_built])
|
|
if present is False:
|
|
print("*" * 72)
|
|
print("BUILD MISMATCH: %s was not produced by %s" % (args.crash, args.exe))
|
|
print(" crash file says: commit %s built %s" % (crash_commit, crash_built))
|
|
print(" those build strings are not present in that exe.")
|
|
print(" Rebuild that commit and point --exe/--pdb at it.")
|
|
print(" Symbols below would be WRONG.")
|
|
print("*" * 72)
|
|
if not args.force:
|
|
sys.exit(3)
|
|
else:
|
|
print("note: the crash file carries no build identity line - cannot "
|
|
"confirm it belongs to this exe")
|
|
|
|
with open(args.crash, "r", errors="replace") as f:
|
|
crash_lines = f.readlines()
|
|
|
|
rva_re = re.compile(r"exe\+0x([0-9A-Fa-f]+)")
|
|
decoded_any = False
|
|
for line in crash_lines:
|
|
stripped = line.rstrip("\n")
|
|
m = rva_re.search(stripped)
|
|
if not m:
|
|
print(stripped)
|
|
continue
|
|
rva = int(m.group(1), 16)
|
|
result = subprocess.run(
|
|
[symbolizer, "--obj=" + args.exe, "--relative-address",
|
|
"--no-inlines", "--output-style=LLVM", "0x%X" % rva],
|
|
capture_output=True, text=True).stdout.strip().splitlines()
|
|
function = result[0].strip() if result else "??"
|
|
location = result[1].strip() if len(result) > 1 else "??"
|
|
print(stripped)
|
|
print(" => %s %s" % (function, location))
|
|
loc_match = re.match(r"(.+):(\d+):\d*", location)
|
|
if loc_match and "recomp" in loc_match.group(1):
|
|
mnemonic, anchor = guest_instruction_at(args.generated, loc_match.group(1),
|
|
int(loc_match.group(2)))
|
|
if mnemonic:
|
|
extra = (" (%s)" % anchor) if anchor else ""
|
|
print(" => guest instruction: %s%s" % (mnemonic, extra))
|
|
decoded_any = True
|
|
|
|
if not decoded_any:
|
|
print("\n(no host frame resolved into generated code - the fault may be "
|
|
"entirely host-side; the crash file's own rex_sub_* naming and the "
|
|
"guest backchain still apply)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|