diff --git a/engine/MUNGA/APP.cpp b/engine/MUNGA/APP.cpp index 792aad9..f0b0507 100644 --- a/engine/MUNGA/APP.cpp +++ b/engine/MUNGA/APP.cpp @@ -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; + } // //-------------------------------------------------------------------------- diff --git a/engine/MUNGA_L4/L4APP.cpp b/engine/MUNGA_L4/L4APP.cpp index f9fdebe..a8b18db 100644 --- a/engine/MUNGA_L4/L4APP.cpp +++ b/engine/MUNGA_L4/L4APP.cpp @@ -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 diff --git a/game/btl4main.cpp b/game/btl4main.cpp index db04a26..5b8b611 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -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 diff --git a/tools/mkdist.py b/tools/mkdist.py index d02eb21..674469d 100644 --- a/tools/mkdist.py +++ b/tools/mkdist.py @@ -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()