Crash self-report + round-completed latch + PDB archiving

CRASH SELF-REPORT (the #35/#41 heisenbug hunt): every unhandled
exception now logs its code, address, and an EBP-walked stack as
module-relative offsets (btl4+0xNNNN) into BT_LOG before dying --
any field crash on any machine hands us a resolvable stack.  The
walker is per-dereference guarded (a corrupt chain truncates).
BT_CRASHTEST=1 exercises the path end-to-end (verified: forced AV
logs code/addr/5-frame stack).  mkdist archives each build's PDB
next to its zip so offsets resolve per distributed version.

Context: a flaky release-only crash in the mech build path surfaced
during #38 rigs -- it moves with build layout, spares neither peer,
and masks under a debugger heap (13 clean cdb runs vs 2/3 crashes
without).  A real latent memory bug; the self-report will catch it
wherever it strikes next.

ROUND-COMPLETED LATCH (issue #38, awaiting verification): relay-mode
clients refuse RunMission after a round has ended -- the lobby-loop
relaunch owns the next round (in-process round 2 rebuilt every
player/mech entity on stale globals; matchlog caught player=3:1->3:36
in one generation).  Latch sets ONLY when the stop ends a RUNNING
mission (a stray pre-mission StopMission must not poison round 1 --
the first cut ate a legit launch, caught + fixed in rig).  Regression
verification pending (contaminated by the heisenbug above).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-24 08:51:50 -05:00
co-authored by Claude Opus 4.8
parent ac7f0cb13a
commit f7daf03507
4 changed files with 113 additions and 0 deletions
+64
View File
@@ -163,6 +163,58 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
#include "btl4console.hpp"
#endif
//
// CRASH SELF-REPORT (2026-07-24, the #35/#41 heisenbug hunt): every unhandled
// exception logs its code, address, and an EBP-chain stack walk as
// MODULE-RELATIVE offsets ("btl4+0x1234") into BT_LOG before the process
// dies -- so any field crash on any machine hands us a resolvable stack (we
// hold the PDB). The walker is guarded per-dereference; a corrupt frame
// chain just truncates the walk.
//
static LONG WINAPI
BTCrashFilter(EXCEPTION_POINTERS *info)
{
unsigned long base = (unsigned long)GetModuleHandleA(NULL);
unsigned long code = info->ExceptionRecord->ExceptionCode;
unsigned long addr = (unsigned long)info->ExceptionRecord->ExceptionAddress;
std::cout << "\n[crash] UNHANDLED EXCEPTION code=0x" << std::hex << code
<< " addr=0x" << addr;
if (addr >= base && addr < base + 0x2000000UL)
std::cout << " (btl4+0x" << (addr - base) << ")";
if (code == 0xC0000005 && info->ExceptionRecord->NumberParameters >= 2)
std::cout << " access=" << std::dec
<< (int)info->ExceptionRecord->ExceptionInformation[0]
<< std::hex << " target=0x"
<< (unsigned long)info->ExceptionRecord->ExceptionInformation[1];
std::cout << "\n[crash] stack:";
unsigned long eip = info->ContextRecord->Eip;
unsigned long ebp = info->ContextRecord->Ebp;
for (int depth = 0; depth < 24; ++depth)
{
std::cout << " ";
if (eip >= base && eip < base + 0x2000000UL)
std::cout << "btl4+0x" << std::hex << (eip - base);
else
std::cout << "0x" << std::hex << eip;
MEMORY_BASIC_INFORMATION mbi;
if (ebp == 0 || (ebp & 3) != 0
|| VirtualQuery((void *)ebp, &mbi, sizeof(mbi)) == 0
|| mbi.State != MEM_COMMIT
|| (mbi.Protect & (PAGE_READWRITE | PAGE_READONLY)) == 0)
break;
unsigned long next_eip = ((unsigned long *)ebp)[1];
unsigned long next_ebp = ((unsigned long *)ebp)[0];
if (next_eip == 0 || next_ebp <= ebp)
break;
eip = next_eip;
ebp = next_ebp;
}
std::cout << std::dec << "\n" << std::flush;
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
// Boot tick for the relaunch storm damper (btl4console.cpp, issue #33):
@@ -172,6 +224,13 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
gBTBootTick = GetTickCount();
}
// Crash self-report (see BTCrashFilter above) -- armed before anything
// else so even boot crashes leave a stack in the log.
SetUnhandledExceptionFilter(BTCrashFilter);
// BT_CRASHTEST=1: deliberately AV after the log opens -- verifies the
// crash filter's forensic path end-to-end on any machine.
// Heap-corruption hunt (env BT_HEAPCHECK=1; default OFF -- ~100x slower): validate
// the whole debug heap on EVERY alloc/free, so an out-of-bounds write is caught at
// the very next heap op (a stack near the culprit) instead of much later at some
@@ -204,6 +263,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
? (std::ios::out | std::ios::app) : std::ios::out);
std::cout.rdbuf(logfile.rdbuf());
if (getenv("BT_CRASHTEST") != NULL && *getenv("BT_CRASHTEST") == '1')
{
*(volatile int *)0 = 0; // BT_CRASHTEST: exercise BTCrashFilter
}
// FIRST-BREATH line, flushed IMMEDIATELY (field 2026-07-23: a player's
// exe died repeatedly with a 0-byte join.log -- indistinguishable from
// "never ran"). After this line, an empty log can only mean the exe was