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
+13
View File
@@ -1724,6 +1724,19 @@ void
extern int gBTMissionStoppedByConsole;
gBTMissionStoppedByConsole = 1;
}
// Issue #38: the round is over -- latch it (relay mode refuses further
// in-process RunMissions; the relaunch owns the next round) and void
// any stale mid-load launch pends so they can't fire post-round.
// ONLY when this stop ends a mission that actually RAN: a stray
// pre-mission StopMission (relay races) must not poison the round-1
// launch (caught live: the latch ate RunMission #2 of round one).
if (GetApplicationState() == RunningMission)
{
extern int gBTRoundCompleted;
extern int gBTRunMissionPendCount;
gBTRoundCompleted = 1;
gBTRunMissionPendCount = 0;
}
//
//--------------------------------------------------------------------------
+26
View File
@@ -571,6 +571,20 @@ Entity*
//
int gBTRunMissionPendCount = 0;
//
// Round-completed latch (issue #38, 2026-07-24): our port replaces the
// arcade's in-process next-round flow with a PROCESS RELAUNCH per round
// (reconstructed gBT* globals don't survive in-process re-init). A
// RunMission that lands after a round has ended (a stale pend, a relay
// resend racing the stop) used to start round 2 IN-PROCESS anyway --
// rebuilding every player/mech as new entities on stale globals (field
// signature: opponents' mechs re-created mid-session with the wrong
// paint; matchlog showed player=3:1->3:36 in one generation). Set by
// StopMissionMessageHandler; in relay mode any later RunMission is
// refused -- the relaunch owns the next round.
//
int gBTRoundCompleted = 0;
int BTTakePendingRunMissions(void)
{
int pended = gBTRunMissionPendCount;
@@ -593,6 +607,18 @@ void
Check(this);
Verify(message->messageID == RunMissionMessageID);
//
// Issue #38: after a completed round, relay mode NEVER runs another
// mission in-process -- the lobby-loop relaunch handles the next round
// with a fresh process (see gBTRoundCompleted above).
//
if (gBTRoundCompleted && getenv("BT_RELAY") != NULL)
{
DEBUG_STREAM << "[launch] RunMission after round end IGNORED -- the "
<< "relaunch owns the next round\n" << std::flush;
return;
}
//
//--------------------------------------------------------------------------
// If the application is already running then ignore this message
+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
+10
View File
@@ -142,5 +142,15 @@ def main():
% (len(bats) + len(exes) + len(tracked), total / 1048576.0,
zsz / 1048576.0))
# Crash-forensics: archive this build's PDB next to the zip (operator-side
# only, never shipped) so the [crash] btl4+0xNNNN offsets in player logs
# stay resolvable per distributed version.
pdb = "build/Release/btl4.pdb"
if os.path.exists(pdb):
import shutil
pdb_dest = zpath[:-4] + ".pdb"
shutil.copyfile(pdb, pdb_dest)
print(" symbols archived: %s" % pdb_dest)
if __name__ == "__main__":
main()