diff --git a/scratchpad/test_map_guard.py b/scratchpad/test_map_guard.py new file mode 100644 index 0000000..7b0daca --- /dev/null +++ b/scratchpad/test_map_guard.py @@ -0,0 +1,71 @@ +"""Map-catalog guard regression (the 2026-07-26 artrucks outage). + + 1. ValueSets.maps offers EXACTLY the Mac 4.10 console catalog (8 maps, + catalog order) -- internal RES fragments (artrucks...) hidden. + 2. The relay warns loudly when an egg names an unplayable map. + 3. A real map stays silent. + 4. The 60s all-pods-stalled hint names the egg's map. +""" +import contextlib +import io +import os +import re +import shutil +import sys +import time + +sys.path.insert(0, r"C:\git\bt411\tools") +os.chdir(r"C:\git\bt411\content") +import btconsole # noqa: E402 +import eggmodel # noqa: E402 + +fails = [] + + +def check(label, ok, extra=""): + print(" %-56s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra)) + if not ok: + fails.append(label) + + +v = eggmodel.ValueSets() +check("dropdown = the console catalog, in order", + v.maps == list(eggmodel.CONSOLE_MAPS), repr(v.maps)) +check("artrucks hidden from the dropdown", "artrucks" not in v.maps) + +shutil.copyfile("FOGDAY.EGG", "_maptest.EGG") +raw = re.sub(rb"^map=\S+", b"map=artrucks", + open("_maptest.EGG", "rb").read(), count=1, flags=re.M) +open("_maptest.EGG", "wb").write(raw) +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + btconsole.Relay(1500, "_maptest.EGG", "127.0.0.1", 0, manual_launch=True) +check("relay warns on a phantom map", + "NOT a playable mission" in buf.getvalue()) + +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + r = btconsole.Relay(1500, "FOGDAY.EGG", "127.0.0.1", 0, manual_launch=True) +check("real map stays silent", "NOT a playable mission" not in buf.getvalue()) + + +class C: + ready = False + + +r.launches_sent = 0 +r.launch_at = time.time() - 61 +r.eggs_released = True +r.by_host = {2: C(), 3: C()} +r._hold_notice_at = 0 +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + r._tick_launch() +check("60s all-stalled hint names the map", + "stalled in MISSION LOAD" in buf.getvalue() + and "map=" in buf.getvalue()) + +os.remove("_maptest.EGG") +print() +print("ALL PASS" if not fails else "FAILURES: %s" % fails) +sys.exit(1 if fails else 0) diff --git a/tools/btconsole.py b/tools/btconsole.py index 688102a..f733141 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -523,6 +523,7 @@ class Relay: # LAUNCH trims the roster to the seats present. self.seat_beacons = {} self.roster = parse_egg_roster(egg_path) + self._warn_unplayable_map() 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))) @@ -686,6 +687,33 @@ class Relay: except OSError: pass + # The Mac 4.10 console's playable-map catalog (one source of truth in + # eggmodel.CONSOLE_MAPS; duplicated here only as an import-failure + # fallback). The RES also carries INTERNAL FRAGMENTS (artrucks, arenall, + # adrop...) that are include-nodes, not missions -- 2026-07-26: map=artrucks + # in the egg held six players at 0/N ready for 40 minutes, every pod + # stalled in mission load with no error anywhere. + _CONSOLE_MAPS_FALLBACK = ("cavern", "grass", "rav", "polar3", "polar4", + "arena1", "arena2", "dbase") + + def _warn_unplayable_map(self): + try: + import re as _re + raw = open(self.orig_egg_path, "rb").read() + m = _re.search(rb"^map=(\S+)", raw, _re.M) + if not m: + return + map_name = m.group(1).decode("ascii", "replace").strip().lower() + playable = tuple(getattr(eggmodel, "CONSOLE_MAPS", ())) or self._CONSOLE_MAPS_FALLBACK + if map_name and map_name not in playable: + print(f"[relay] *** WARNING: egg map='{map_name}' is NOT a " + f"playable mission (playable: {', '.join(playable)}). " + f"An art/include fragment STALLS EVERY POD'S LOAD " + f"forever (the 2026-07-26 outage). Fix the map before " + f"launching.", flush=True) + except OSError: + pass + def _reload_egg_file(self): """BETWEEN-ROUNDS MISSION EDITS (2026-07-23): re-read the session egg FILE so operator changes (arena/time/weather/length via the console's @@ -703,6 +731,7 @@ class Relay: open(self.orig_egg_path, "rb").read()) self.egg_bytes = self.orig_egg_bytes self.egg_path = self.orig_egg_path + self._warn_unplayable_map() new_length = parse_egg_mission_length(self.orig_egg_path) if new_length != self.mission_length: print(f"[relay] mission length now {new_length:.0f}s", @@ -1187,6 +1216,7 @@ class Relay: self.launch_at = max(time.time(), self.eggs_done_at + LAUNCH_SETTLE_SECONDS) wait = max(0.0, self.launch_at - time.time()) + self._stall_hint_shown = False # fresh hint per launch print(f"[relay] operator LAUNCH received; firing in " f"{wait:.0f}s", flush=True) self._log_launch_readiness() @@ -1227,6 +1257,28 @@ class Relay: print(f"[relay] launch HELD -- still loading: {names} " f"({len(self.by_host) - len(not_ready)}/" f"{len(self.by_host)} ready)", flush=True) + # PHANTOM-MISSION SIGNATURE (2026-07-26): EVERY pod stuck at + # 0/N for 60s+ means they are all stalled in mission load -- + # shared content, not a slow machine. Name the map once so + # the operator is not left guessing for 40 minutes again. + if (len(not_ready) == len(self.by_host) + and now >= self.launch_at + 60.0 + and not getattr(self, "_stall_hint_shown", False)): + self._stall_hint_shown = True + try: + import re as _re + raw = open(self.orig_egg_path, "rb").read() + m = _re.search(rb"^map=(\S+)", raw, _re.M) + map_name = (m.group(1).decode("ascii", "replace") + if m else "?") + except OSError: + map_name = "?" + print(f"[relay] *** 0/{len(self.by_host)} ready after " + f"60s: every pod is stalled in MISSION LOAD. " + f"Suspect the mission content itself -- the egg " + f"says map={map_name}. (An unplayable map hangs " + f"all pods with no error; End Mission -> fix the " + f"map -> Re-arm -> LAUNCH.)", flush=True) return if not_ready: print(f"[relay] launch FORCED after 180s hold -- " diff --git a/tools/eggmodel.py b/tools/eggmodel.py index 10f9e02..06296c3 100644 --- a/tools/eggmodel.py +++ b/tools/eggmodel.py @@ -45,6 +45,17 @@ DROPZONES = ["one"] # only confirmed-valid name (embedded in map streams) # crash). Canonical 8-mech names first for the operator dropdown; the # short variants (bhk1/mad1/lok2/...) are alternate ModelList entries of # the same stable. "camera" = the spectator CameraShip, not a mech. +# The AUTHENTIC operator map catalog -- the Mac 4.10 console's adventure tree +# (Console.ini; provenance docs/GLASS_COCKPIT.md 2026-07-19; same list the solo +# menu ships in game/glass/btl4fe.cpp kMaps). The RES also holds INTERNAL +# FRAGMENTS (artrucks, arenall, adrop, pol4sfx...) that pass the type-14+26 +# existence check but are include-nodes, not missions -- picking one stalls +# every pod's load forever (the 2026-07-26 games-night outage: map=artrucks +# held six players at 0/6 ready for 40 minutes). Offer what the 1995 console +# offered. +CONSOLE_MAPS = ["cavern", "grass", "rav", "polar3", "polar4", + "arena1", "arena2", "dbase"] + KNOWN_GOOD_VEHICLES = ["bhk1", "madcat", "avatar", "blkhawk", "loki", "owens", "sunder", "thor", "vulture"] CANDIDATE_VEHICLES = ["ava1", "lok1", "lok2", "mad1", "mad2", "own1", @@ -105,7 +116,12 @@ class ValueSets: # maps: need BOTH type 14 and type 26 maps14 = {r.name for r in by_type[14]} maps26 = {r.name for r in by_type[26]} - self.maps = sorted(maps14 & maps26) + present = maps14 & maps26 + # catalog order, filtered to what THIS RES actually carries; fall back + # to the permissive set only if the intersection is somehow empty (a + # foreign RES), so the tool never presents zero maps. + self.maps = [m for m in CONSOLE_MAPS if m in present] or sorted(present) + self.all_map_streams = sorted(present) # fragments included (debug) # vehicletable (type 25) self.colors, self.badges, self.patches = [], [], [] for r in by_type[25]: