From 68ffe556ce88f52f2a9fda8bdfbd08fd67566b14 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Tue, 28 Jul 2026 20:09:36 -0500 Subject: [PATCH] make a field crash resolvable: no Release PDB existed, and /O2 had eaten the frame pointers BTCrashFilter writes `[crash] ... addr=0x... (btl4+0xNNNN)` plus an EBP-chain walk into the tester's day log, and its own comment claims "we hold the PDB". We did not. Two independent gaps, both fatal to the point of the thing: 1. No Release PDB was produced AT ALL -- only build/Debug/btl4.pdb, and the shipped exe embedded no PDB path. btl4+0xNNNN from a tester could not be turned into a function name by anyone. 2. Release flags were /O2 /Ob2 /DNDEBUG with no /Oy- anywhere, and /O2 implies /Oy on x86. The walker follows EBP, so the stack it printed was unreliable even when the faulting address was not. Net effect: when the Owens crash finally lands we would have received an address nobody could resolve, and a call chain we could not trust. Cheaper to fix before tonight's session than to wait for the crash to happen twice. /Zi emit debug info -> a PDB. Does not change codegen. /Oy- keep EBP as a frame pointer so the walk is trustworthy. /DEBUG + /OPT:REF + /OPT:ICF -- /DEBUG turns the last two OFF by default, which would have quietly bloated the shipped exe with unreferenced code. The exe grew 512 bytes, the debug directory entry, and nothing else. Also emitting /MAP, which turned out to matter: this machine has no cdb, and a tester's operator may not have one either. The .map is plain text, so an offset can be resolved with a text editor. tools/symcrash.py does it properly -- nearest-preceding-symbol against the archived map, module-external addresses (ntdll, kernel32) passed through untouched since they are not ours. Verified end to end rather than assumed, using the BT_CRASHTEST=1 hook that exists for exactly this: [crash] addr=0xb8260 (btl4+0x8260) access=1 target=0x0 [crash] stack: btl4+0x8260 btl4+0xfe7ed 0x763ffcc9 ... btl4+0x8260 -> _WinMain@16 +0x7e0 btl4+0xfe7ed -> __scrt_common_main_seh +0xf8 which is exactly right: BT_CRASHTEST does *(volatile int *)0 = 0 inside WinMain, called from the CRT entry. Correct stack AND correct names. The PDB and map are archived per build and never shipped: .gitignore already covers *.pdb and dist/, and mkdist only picks up .dll next to the exe, so neither can reach a zip by accident. The session header stamps build=4.11. (), so an archived pair matches a tester's log exactly. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 27 +++++++++++++++ tools/symcrash.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tools/symcrash.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a26664..2dee8d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,19 @@ if(BT_STEAM) endif() set(BT_OPTS /permissive /W0 /wd4996 /EHsc /bigobj /MP) +# FIELD CRASHES MUST BE SYMBOLIZABLE (2026-07-28). +# BTCrashFilter (btl4main.cpp) writes `[crash] ... addr=0x... (btl4+0xNNNN)` +# plus an EBP-chain walk into the tester's day log. Both halves were useless +# in a shipped build: no Release PDB was produced at all, so btl4+0xNNNN could +# not be resolved to a function, and /O2 implies /Oy (frame-pointer omission), +# so the walk itself was unreliable. A tester handed us an address nobody +# could turn into a name -- which is the whole point of the self-report. +# /Zi emit debug info -> a PDB. Does NOT change codegen. +# /Oy- keep EBP as a frame pointer so the stack walk is trustworthy. +# The PDB is ARCHIVED per build (dist/pdb/), never shipped: mkdist only picks +# up .dll next to the exe, so it cannot leak into a zip by accident. +list(APPEND BT_OPTS $<$:/Zi> $<$:/Oy->) + #===========================================================================# # Engine: MUNGA (shared sim/render engine) + MUNGA_L4 (Win32/D3D9 HAL). # The 2007 RP411 Windows engine, carrying our BT render/loader work @@ -424,6 +437,20 @@ target_link_libraries(btl4 PRIVATE # UNRESOLVED tolerates the dead offline-tool factory in mech3.cpp. See docs. target_link_options(btl4 PRIVATE /FORCE) +# ...and emit the PDB the crash filter's offsets are resolved against (see the +# /Zi note by BT_OPTS). /DEBUG turns OFF /OPT:REF and /OPT:ICF by default, which +# would silently bloat the shipped exe with unreferenced code, so put both back: +# the goal is a symbol file BESIDE an otherwise-unchanged Release binary. +# /MAP as well as the PDB, deliberately: the .map is PLAIN TEXT, so a +# `btl4+0xNNNN` from a tester's log can be resolved to a function name with a +# text editor and no debugger installed at all (this machine has no cdb). +# tools/symcrash.py does the lookup. Archive both per build. +target_link_options(btl4 PRIVATE + $<$:/DEBUG> + $<$:/OPT:REF> + $<$:/OPT:ICF> + $<$:/MAP>) + # Steam transport (BT_STEAM=ON only -- the license gate): DELAY-LOADED. # steam_api.dll is only mapped when the first SteamAPI call runs, and every # call site gates on BT_STEAM_NET=1 (btl4main step-4b bring-up; L4NET's diff --git a/tools/symcrash.py b/tools/symcrash.py new file mode 100644 index 0000000..f7b284d --- /dev/null +++ b/tools/symcrash.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""symcrash.py -- turn a tester's [crash] line into function names. + +BTCrashFilter (btl4main.cpp) writes module-relative offsets into the day log: + + [crash] UNHANDLED EXCEPTION code=0xc0000005 addr=0xb8260 (btl4+0x8260) ... + [crash] stack: btl4+0x8260 btl4+0xfe7ed 0x763ffcc9 ... + +Those offsets mean nothing without the symbols for the EXACT build that produced +them -- which is why every session header carries `build=4.11. ()` and +why the linker .map is archived per build alongside the PDB. + +Usage: + python tools/symcrash.py "btl4+0x8260" + python tools/symcrash.py (scans for [crash]) + +Resolution is nearest-preceding-symbol, so the answer is the function the +address falls INSIDE, plus the byte offset into it. Addresses outside the +module (kernel32, ntdll) are passed through untouched -- they are not ours. +""" +import re +import sys + + +def load_map(path): + """Return (sorted [(rva, name)], preferred_base).""" + syms = [] + base = None + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if base is None: + m = re.search(r"Preferred load address is ([0-9A-Fa-f]+)", line) + if m: + base = int(m.group(1), 16) + continue + # " 0001:00000a10 ?Foo@Bar@@QAEXXZ 00401a10 f thing.obj" + m = re.match(r"\s+[0-9A-Fa-f]{4}:[0-9A-Fa-f]{8}\s+(\S+)\s+([0-9A-Fa-f]{8,16})\s", + line) + if m and base is not None: + syms.append((int(m.group(2), 16) - base, m.group(1))) + syms.sort(key=lambda s: s[0]) + return syms, base + + +def resolve(syms, rva): + lo, hi, best = 0, len(syms) - 1, None + while lo <= hi: # nearest preceding symbol + mid = (lo + hi) // 2 + if syms[mid][0] <= rva: + best = syms[mid] + lo = mid + 1 + else: + hi = mid - 1 + return best + + +def main(): + if len(sys.argv) < 3: + sys.exit(__doc__) + syms, base = load_map(sys.argv[1]) + if not syms: + sys.exit("no symbols parsed from %s -- is it a linker .map?" % sys.argv[1]) + print("%d symbols, preferred base 0x%x\n" % (len(syms), base)) + + arg = sys.argv[2] + try: # a log file? + text = open(arg, "r", encoding="utf-8", errors="replace").read() + except OSError: + text = arg # ...no, a literal offset + + for line in text.splitlines() or [text]: + if "btl4+" not in line: + continue + if line.lstrip().startswith("[crash]"): + print(line.strip()) + for off in re.findall(r"btl4\+0x([0-9A-Fa-f]+)", line): + rva = int(off, 16) + hit = resolve(syms, rva) + if hit is None: + print(" btl4+0x%-8x " % rva) + else: + print(" btl4+0x%-8x %s +0x%x" % (rva, hit[1], rva - hit[0])) + print() + + +if __name__ == "__main__": + main()