From 3a9b35fc0644dde1bf969c9dac747c58fa01ba0c Mon Sep 17 00:00:00 2001 From: arcattack Date: Wed, 22 Jul 2026 18:57:50 -0500 Subject: [PATCH] Round reset + device-creation retry + load-time pump (rejoin trilogy) 1. ROUND RESET (the "picked Hellbringer, got a Blackhawk" field bug): trim/prefs finalize the session egg for ONE round, and that was permanent for the relay's lifetime -- a rejoin after a mission or crash got the stale finalized egg and its new callsign/mech request was recorded but never applied. _maybe_reset_round() (both drop paths) restores the original egg/roster/launch state once every pod is gone; live beacons re-key by tag position (conn.seat_tag). Verified two-round e2e: blkhawk mission -> exit -> RESET -> lok1 request -> egg rewritten -> the Hellbringer spawned. 2. DEVICE-CREATION RETRY: CreateDevice(HARDWARE_VERTEXPROCESSING) fails transiently when a just-exited instance still holds the adapter (caught live; also the parked "exit 139" join crash). The old double-failure path called PostQuitMessage(1) -- which does NOT stop execution -- and the NULL mDevice deref right after was the real crash. Now: HW->SW retry with 500ms backoff and a 20s deadline, then a clean error box + ExitProcess. 3. LOAD-TIME PUMP: mission load is thousands of back-to-back synchronous model loads; the starved message pump ghosted the window "Not Responding" mid-load. BTLoadPump() (hooked from d3d_OBJECT::LoadObject) pumps + repaints the wait screen at most every 150ms pre-run; no-op once the scene is live. Also: round-reset originals captured after the roster parse (the first cut crashed the relay constructor -- caught by the two-round test). Co-Authored-By: Claude Opus 4.8 (1M context) --- context/multiplayer.md | 20 +++++++++++ engine/MUNGA_L4/L4D3D.cpp | 3 ++ engine/MUNGA_L4/L4VIDEO.cpp | 67 +++++++++++++++++++++++++++++++++---- tools/btconsole.py | 51 ++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/context/multiplayer.md b/context/multiplayer.md index 03bc709..de8e87d 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -425,6 +425,26 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. deferrals so a wedged load still surfaces the original Fail. `[launch] ... deferred +1s` log per deferral. Verified: the crashing shape deferred 104x through a ~50s load, then launched and ran clean. +- **ROUND RESET (2026-07-22) [T2]**: trim/prefs FINALIZE the egg for one round -- and that used + to be permanent for the relay's lifetime, so a REJOIN after a mission (or crash) was served + the stale finalized egg and its new callsign/mech request was recorded but never applied (the + field "picked Hellbringer, got a Blackhawk"). Now `_maybe_reset_round()` (triggered from both + drop paths) restores the ORIGINAL egg/roster/launch state once every pod is gone; live beacons + (players already waiting for the next round) re-key by their TAG's position in the restored + roster (`conn.seat_tag`, captured at assign). Verified two-round e2e: round 1 blkhawk mission + -> pod exit -> RESET -> round 2 requested lok1 -> egg rewritten vehicle=lok1 -> the pod + spawned the Hellbringer. Operator flow for code updates: the relay is spawned per Start + Session -- a session RESTART picks up new btconsole code. +- **DEVICE-CREATION RETRY (2026-07-22) [T2]**: `CreateDevice(HARDWARE_VERTEXPROCESSING)` can + fail TRANSIENTLY (a just-exited instance still holds the adapter -- caught live: a round-2 + pod died while round-1 tore down; also the parked "exit 139 during join"). The old code had + no retry AND its double-failure path called PostQuitMessage(1) which does NOT stop execution + -- the NULL mDevice deref right after was the actual crash. Now: HW->SW retry loop, 500ms + backoff, 20s deadline, then a clean MessageBox + ExitProcess (L4VIDEO.cpp). +- **LOAD-TIME PUMP (2026-07-22) [T2]**: the mission load is thousands of small synchronous + model loads back-to-back -- the message pump starved and the window ghosted "Not Responding" + mid-load. `BTLoadPump()` (L4VIDEO, hooked from `d3d_OBJECT::LoadObject`) pumps + repaints the + wait screen at most every 150ms during pre-run states; no-op once the scene is live. - **PATIENT WALK-UP (2026-07-18) [T2]**: a pod dialed at a relay that isn't up yet no longer aborts (release `Fail()` is a bare `abort()` = looked like a "crash", field report). Both the seat request (`RelayRequestSeat`) and LAN discovery (`BT_RELAY=auto`) now RETRY (2s dials, 30 diff --git a/engine/MUNGA_L4/L4D3D.cpp b/engine/MUNGA_L4/L4D3D.cpp index f1e8107..7dbc039 100644 --- a/engine/MUNGA_L4/L4D3D.cpp +++ b/engine/MUNGA_L4/L4D3D.cpp @@ -75,6 +75,9 @@ struct BTLoadTimer d3d_OBJECT *d3d_OBJECT::LoadObject(LPDIRECT3DDEVICE9 device, char *fileName) { + // keep the window alive through long synchronous load stretches + extern void BTLoadPump(void); + BTLoadPump(); BTLoadTimer _lt(fileName); char fullPath[512]; sprintf(fullPath, "VIDEO\\%s", fileName); diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index b34fe1a..4aaac9f 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -3474,15 +3474,41 @@ DPLRenderer::DPLRenderer( // the view; the GPU sat idle, Present <1ms). Try HARDWARE first (any modern // or pod GPU has fixed-function T&L); fall back to software if it fails, as // the original error message always intended. - V(gD3D->CreateDevice(*mPrimaryIndex, D3DDEVTYPE_HAL, hWnd, D3DCREATE_HARDWARE_VERTEXPROCESSING, &mPresentParams, &mDevice)); - if (FAILED(hr)) + // DEVICE-CREATION RETRY (2026-07-22): a transient failure is REAL -- a + // just-exited instance can still hold the adapter for a moment (caught + // live: a round-2 pod died here while round-1 tore down). The old code + // had TWO bugs: no retry, and on double failure PostQuitMessage(1) did + // NOT stop execution -- the NULL mDevice deref right below was the + // actual crash (the intermittent "exit 139" / silent join death). + // Retry HW->SW with a short backoff; on true exhaustion exit CLEANLY + // with a visible message. { - DEBUG_STREAM<<"Couldn't create HARDWARE_VERTEXPROCESSING device."<CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &mPresentParams, &mDevice)); - if (FAILED(hr)) + unsigned long device_deadline = GetTickCount() + 20000UL; + for (;;) { - PostQuitMessage(1); + hr = gD3D->CreateDevice(*mPrimaryIndex, D3DDEVTYPE_HAL, hWnd, + D3DCREATE_HARDWARE_VERTEXPROCESSING, &mPresentParams, &mDevice); + if (SUCCEEDED(hr) && mDevice != NULL) + break; + DEBUG_STREAM << "Couldn't create HARDWARE_VERTEXPROCESSING device (hr=0x" + << std::hex << (unsigned)hr << std::dec << ")." << std::endl << std::flush; + hr = gD3D->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, + D3DCREATE_SOFTWARE_VERTEXPROCESSING, &mPresentParams, &mDevice); + if (SUCCEEDED(hr) && mDevice != NULL) + break; + DEBUG_STREAM << "SOFTWARE_VERTEXPROCESSING failed too (hr=0x" + << std::hex << (unsigned)hr << std::dec + << ") -- retrying in 500ms" << std::endl << std::flush; + if (GetTickCount() >= device_deadline) + { + MessageBoxA(NULL, + "Could not create a Direct3D device after 20 seconds.\n\n" + "Another program may be holding the graphics adapter.\n" + "Close other 3D applications and try again.", + "BattleTech -- graphics init failed", MB_OK | MB_ICONERROR); + ExitProcess(1); // CLEAN exit, not the NULL-deref crash + } + Sleep(500); } } @@ -8858,6 +8884,33 @@ void BTWaitScreenPaint(const char *line1, const char *line2) // with live game frames. Logical gBTSceneHasPresented = False; +// +// BTLoadPump -- throttled message pump + wait-screen repaint for the LONG +// SYNCHRONOUS stretches of mission load (thousands of small model/texture +// loads back-to-back starve the message pump; the window ghosts "Not +// Responding" and the animation freezes -- user report 2026-07-22). Called +// from the model-load hot path (L4D3D LoadObject); at most one pump per +// 150ms so the load itself is unaffected. Pre-run states only: once the +// scene is live the render loop owns pumping and this is a no-op. +// +void BTLoadPump(void) +{ + if (gBTSceneHasPresented) + return; + static unsigned long s_lastPump = 0; + unsigned long now = GetTickCount(); + if (now - s_lastPump < 150UL) + return; + s_lastPump = now; + MSG msg; + while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessageA(&msg); + } + BTWaitScreenPaint("LOADING MISSION", "stand by"); +} + void DPLRenderer::ExecuteIdle() { HRESULT hr; diff --git a/tools/btconsole.py b/tools/btconsole.py index cd49737..0a069b1 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -313,6 +313,13 @@ class Relay: # meanwhile. self.seat_prefs = {} self.eggs_released = False + # ROUND RESET originals (2026-07-22): trim/prefs FINALIZE the egg for + # one round; when every pod leaves, the round resets to these so the + # NEXT round's joins hold/rewrite a FRESH egg. (Field report: a + # rejoin after a crash got the stale finalized egg -- the new mech + # request was recorded but never applied.) + self.orig_egg_path = egg_path + self.orig_egg_bytes = self.egg_bytes # PRESENCE BEACONS (2026-07-22, "launch with whoever connects"): the # pod KEEPS its seat-request TCP connection open until it exits; a # live beacon = a seated player. A beacon dropping before the seat @@ -320,6 +327,7 @@ class Relay: # LAUNCH trims the roster to the seats present. self.seat_beacons = {} self.roster = parse_egg_roster(egg_path) + self.orig_roster = list(self.roster) # round-reset restore point self.expected_ids = set(range(FIRST_GAME_HOST_ID, FIRST_GAME_HOST_ID + len(self.roster))) # OPERATOR-RESERVED SEATS: roster seats the operator flies LOCALLY (with @@ -545,6 +553,46 @@ class Relay: print(f"[relay] seat-pref egg rewrite FAILED ({e!r}) -- " f"serving the original egg", flush=True) + def _maybe_reset_round(self): + # All pods gone from a FINALIZED round -> restore the template so the + # next round's joins are honored (held egg, fresh prefs/trim). Live + # beacons (players already waiting for the next round) are re-keyed + # by their TAG's position in the restored roster. + if not self.eggs_released: + return + if self.by_host or any(c.acked for c in self.console_conns): + return + self.eggs_released = False + self.egg_path = self.orig_egg_path + self.egg_bytes = self.orig_egg_bytes + self.roster = list(self.orig_roster) + self.expected_ids = set(range(FIRST_GAME_HOST_ID, + FIRST_GAME_HOST_ID + len(self.roster))) + self.launches_sent = 0 + self.launch_at = None + self.launch_requested = False + self.eggs_done_at = None + self._launch_blocked_warned = False + self.stop_sent = False + self.stop_requested = False + self.mission_started_at = None + old_beacons = self.seat_beacons + old_prefs = self.seat_prefs + self.seat_beacons = {} + self.seat_prefs = {} + self.seat_reservations = {} + for old_id, conn in old_beacons.items(): + tag = getattr(conn, "seat_tag", None) + if tag in self.roster: + new_id = FIRST_GAME_HOST_ID + self.roster.index(tag) + conn.seat_host = new_id + self.seat_beacons[new_id] = conn + if old_id in old_prefs: + self.seat_prefs[new_id] = old_prefs[old_id] + print(f"[relay] round RESET -- roster/egg restored " + f"({len(self.seat_beacons)} player(s) already waiting)", + flush=True) + def _pods_ready(self): """REAL pods = console connections that ACKED the egg (scanners never speak the protocol, so they can't hold a roster slot).""" @@ -620,6 +668,7 @@ class Relay: pass if conn in self.console_conns: self.console_conns.remove(conn) + self._maybe_reset_round() def _log_launch_readiness(self): # PRE-LAUNCH READINESS (2026-07-18): make the "launching short" footgun @@ -834,6 +883,7 @@ class Relay: self._send_raw(conn, struct.pack(ENV_FMT_TCP, ROUTE_SEAT_ASSIGN, len(payload_out)) + payload_out) conn.seat_host = host_id # this conn IS the beacon now + conn.seat_tag = tag # tag survives a round reset self.seat_beacons[host_id] = conn print(f"[relay] {conn.name()} seat request -> assigned host " f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)" @@ -929,6 +979,7 @@ class Relay: self.udp_endpoint.pop(conn.host_id, None) for other in self.by_host.values(): self._send_control(other, ROUTE_PEER_DOWN, conn.host_id) + self._maybe_reset_round() # ---------------- game UDP side ----------------