Mission clock reconstructed: the console ends the timed mission
User: 'isn't the game supposed to time out tho?' Yes -- and it was HALF-implemented: the egg's [mission] length drives the pods' countdown and the final-30s ranking window, and the full StopMission -> fade -> end chain exists, but nothing ever fired it: in 1995 the CONSOLE sent StopMission at expiry. Our console never did, so missions ran forever. - btconsole relay: arms the clock when RunMission #2 fires; sends Application::StopMissionMessage (clientID 4, msgID 6 per APP.h:383, exitCode NullExitCodeID) at length + 2s grace; stdin 'stop' command ends the mission early (works in auto and manual modes). - btoperator: END MISSION button (enabled once launched; resets per session). Verified live: 40s test mission -- relay logged the armed clock, sent StopMission on time, the pod ran the authentic end chain and exited cleanly ('[boot] RunMissions returned'). Standard eggs carry length=600, so real sessions are now authentic 10-minute pod missions with the score display in the last 30 seconds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f9f230c62b
commit
f57d25f467
@@ -354,6 +354,18 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
|
||||
bitmapindexes. MP_BHMC.EGG template fixed (authentic Aeolus raster preserved bit-exact,
|
||||
Boreas generated). Verified: eggmodel self-test 20/20; ASCII-art decode of app-generated
|
||||
VIPER/MONGOOSE rasters; live 2-node session on the generated egg.
|
||||
- **MISSION CLOCK (2026-07-18) [T2 live]**: timed missions are authentic and were HALF-implemented
|
||||
— the egg's `[mission] length=` (600 = the 10-min pod mission) drives the pod-side countdown
|
||||
(`secondsRemainingInGame`, APP.cpp:652) and the ranking-window display for the final 30s
|
||||
(DIRECTOR.cpp:113), and the full end chain exists (StopMission id 6 → EndingMission → player
|
||||
fade → Stop) — but NOTHING fired it: in 1995 the CONSOLE sent StopMission at expiry (mission
|
||||
length enforcement is console-side). Now reconstructed: the relay arms the clock at RunMission
|
||||
#2 and sends `Application::StopMissionMessage` (clientID 4, msgID 6, +int exitCode 0) at
|
||||
length+2s grace; the operator app gets an ⏹ END MISSION button (stdin `stop`, like `launch`).
|
||||
Pod behavior at stop: authentic fade → mission loop exits → process exits cleanly
|
||||
(`[boot] RunMissions returned`). Verified live: 40s test mission ended on the clock. So real
|
||||
sessions now END after the configured length with the score display in the last 30 seconds —
|
||||
the arcade experience, complete.
|
||||
- **4-POD SESSION VERIFIED (2026-07-18) [T2 live]**: first >2-player run — 4 pods, relay-assigned
|
||||
seats (no BT_SELF), 4-pilot egg (`content/MP4.EGG`, generated via eggmodel incl. 4 callsign
|
||||
bitmaps): 4 distinct seats, 4/4 registered, launch, all 4 "All connections completed", **every
|
||||
|
||||
+77
-8
@@ -82,8 +82,10 @@ CONSOLE_HOST_ID = 1 # FirstLegalHostID
|
||||
# the settle delay. Constants from BT_NET_PROBE=1.
|
||||
APPLICATION_CLIENT_ID = 4 # NetworkClient::ApplicationClientID
|
||||
RUN_MISSION_MESSAGE_ID = 5 # Application::RunMissionMessageID
|
||||
STOP_MISSION_MESSAGE_ID = 6 # Application::StopMissionMessageID (APP.h:383)
|
||||
LAUNCH_SETTLE_SECONDS = 20.0 # egg -> first launch (pods must reach WaitingForLaunch)
|
||||
LAUNCH_STEP_SECONDS = 4.0 # first launch -> second (Launching -> Running)
|
||||
STOP_GRACE_SECONDS = 2.0 # past the pods' own zero so the ranking shows
|
||||
|
||||
|
||||
def run_mission_packet():
|
||||
@@ -93,6 +95,35 @@ def run_mission_packet():
|
||||
return pkt
|
||||
|
||||
|
||||
def stop_mission_packet():
|
||||
# Application::StopMissionMessage {exitCode=NullExitCodeID} (id 6,
|
||||
# APPMSG.cpp:21). The 1995 console ENDED the timed mission -- the pods
|
||||
# only display the countdown (secondsRemainingInGame, APP.cpp:652) and
|
||||
# pop the ranking window for the final 30s (DIRECTOR.cpp:113); the stop
|
||||
# itself was always console-side, which is us.
|
||||
pkt = struct.pack(PKT_FMT_HDR, APPLICATION_CLIENT_ID, 0, CONSOLE_HOST_ID, 0)
|
||||
pkt += struct.pack("<IiIi", 16, STOP_MISSION_MESSAGE_ID, RELIABLE_FLAG, 0)
|
||||
assert len(pkt) == 16 + 16
|
||||
return pkt
|
||||
|
||||
|
||||
def parse_egg_mission_length(egg_path):
|
||||
"""[mission] length= in seconds (0 = untimed)."""
|
||||
section = None
|
||||
try:
|
||||
for raw in open(egg_path, "r", encoding="latin-1"):
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].lower()
|
||||
elif section == "mission" and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip().lower() == "length":
|
||||
return float(value.strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def egg_wire_bytes(egg_text_bytes):
|
||||
"""Convert raw egg TEXT to the wire form NotationFile::ReadText expects:
|
||||
NUL-separated lines (NOTATION.cpp:1043 walks `strchr(buffer,'\\0')+1` per
|
||||
@@ -252,6 +283,10 @@ class Relay:
|
||||
self.udp_drop_pct = udp_drop_pct
|
||||
self.manual_launch = manual_launch
|
||||
self.launch_requested = False # set by the operator (stdin)
|
||||
self.stop_requested = False # operator 'stop' (End Mission)
|
||||
self.mission_length = parse_egg_mission_length(egg_path)
|
||||
self.mission_started_at = None # set when RunMission #2 fires
|
||||
self.stop_sent = False
|
||||
self.eggs_done_at = None
|
||||
self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read())
|
||||
self.roster = parse_egg_roster(egg_path)
|
||||
@@ -317,6 +352,7 @@ class Relay:
|
||||
elif kind == "disc":
|
||||
self._disc_read()
|
||||
self._tick_launch()
|
||||
self._tick_stop()
|
||||
self._tick_stats()
|
||||
|
||||
def _listener(self, port):
|
||||
@@ -441,6 +477,37 @@ class Relay:
|
||||
self.launch_at = time.time() + LAUNCH_STEP_SECONDS
|
||||
print(f"[relay] RunMission #{self.launches_sent} sent to "
|
||||
f"{len(self.console_conns)} pod(s)", flush=True)
|
||||
if self.launches_sent == 2:
|
||||
self.mission_started_at = time.time()
|
||||
if self.mission_length > 0:
|
||||
print(f"[relay] mission clock: {self.mission_length:.0f}s "
|
||||
"(StopMission at expiry)", flush=True)
|
||||
|
||||
def _tick_stop(self):
|
||||
# THE MISSION CLOCK (console-side, as in 1995): send StopMission when
|
||||
# the egg's [mission] length expires, or on the operator's 'stop'
|
||||
# command (the End Mission button).
|
||||
if self.stop_sent or self.launches_sent < 2:
|
||||
return
|
||||
expired = (self.mission_length > 0
|
||||
and self.mission_started_at is not None
|
||||
and time.time() >= self.mission_started_at
|
||||
+ self.mission_length + STOP_GRACE_SECONDS)
|
||||
if not (expired or self.stop_requested):
|
||||
return
|
||||
pkt = stop_mission_packet()
|
||||
for conn in list(self.console_conns):
|
||||
try:
|
||||
conn.sock.setblocking(True)
|
||||
conn.sock.sendall(pkt)
|
||||
conn.sock.setblocking(False)
|
||||
except OSError as e:
|
||||
print(f"[relay] stop send failed to {conn.addr}: {e!r}",
|
||||
flush=True)
|
||||
self.stop_sent = True
|
||||
why = "operator END MISSION" if self.stop_requested else "time expired"
|
||||
print(f"[relay] StopMission sent to {len(self.console_conns)} pod(s) "
|
||||
f"({why})", flush=True)
|
||||
|
||||
# ---------------- game TCP side ----------------
|
||||
|
||||
@@ -695,14 +762,16 @@ def relay_main(argv):
|
||||
if not relay.roster:
|
||||
print(f"[relay] ERROR: no [pilots] entries in {egg_path}")
|
||||
return 2
|
||||
if manual:
|
||||
# Operator command channel: a daemon thread reads stdin; the line
|
||||
# 'launch' fires the mission (the operator app's Launch button).
|
||||
def stdin_reader():
|
||||
for line in sys.stdin:
|
||||
if line.strip().lower() == "launch":
|
||||
relay.launch_requested = True
|
||||
threading.Thread(target=stdin_reader, daemon=True).start()
|
||||
# Operator command channel: a daemon thread reads stdin. 'launch' fires
|
||||
# the mission (manual mode only); 'stop' ends it early (End Mission).
|
||||
def stdin_reader():
|
||||
for line in sys.stdin:
|
||||
command = line.strip().lower()
|
||||
if command == "launch":
|
||||
relay.launch_requested = True
|
||||
elif command == "stop":
|
||||
relay.stop_requested = True
|
||||
threading.Thread(target=stdin_reader, daemon=True).start()
|
||||
try:
|
||||
relay.run()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -287,6 +287,11 @@ class Operator(QMainWindow):
|
||||
"font-weight:bold; color:#33cc55; padding:4px 14px;")
|
||||
self.launch_btn.clicked.connect(self._launch_mission)
|
||||
self.launch_btn.setEnabled(False)
|
||||
self.end_btn = QPushButton("⏹ END MISSION")
|
||||
self.end_btn.setStyleSheet(
|
||||
"font-weight:bold; color:#cc5533; padding:4px 14px;")
|
||||
self.end_btn.clicked.connect(self._end_mission)
|
||||
self.end_btn.setEnabled(False)
|
||||
self.launch_local_btn = QPushButton("Launch local instances")
|
||||
self.launch_local_btn.clicked.connect(self._launch_local)
|
||||
self.launch_local_btn.setEnabled(False)
|
||||
@@ -297,6 +302,7 @@ class Operator(QMainWindow):
|
||||
srow.addWidget(self.restart_btn)
|
||||
srow.addSpacing(20)
|
||||
srow.addWidget(self.launch_btn)
|
||||
srow.addWidget(self.end_btn)
|
||||
srow.addSpacing(20)
|
||||
srow.addWidget(self.launch_local_btn)
|
||||
srow.addWidget(self.stop_games_btn)
|
||||
@@ -524,6 +530,7 @@ class Operator(QMainWindow):
|
||||
# ------------------------------------------------------------- session --
|
||||
|
||||
def _start_session(self):
|
||||
self.end_sent = False
|
||||
problems = self._collect_egg()
|
||||
if problems:
|
||||
QMessageBox.warning(self, "Cannot start", "\n".join(problems))
|
||||
@@ -569,6 +576,7 @@ class Operator(QMainWindow):
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.restart_btn.setEnabled(False)
|
||||
self.launch_btn.setEnabled(False)
|
||||
self.end_btn.setEnabled(False)
|
||||
self.launch_local_btn.setEnabled(False)
|
||||
self.pod_status.setText("session not running")
|
||||
self.log.appendPlainText("== session stopped ==")
|
||||
@@ -585,8 +593,18 @@ class Operator(QMainWindow):
|
||||
if self.console_proc:
|
||||
self.console_proc.write(b"launch\n")
|
||||
self.launch_btn.setEnabled(False)
|
||||
self.end_btn.setEnabled(True)
|
||||
self.log.appendPlainText(">> operator LAUNCH sent")
|
||||
|
||||
def _end_mission(self):
|
||||
"""End the running mission now (the relay also auto-stops at the
|
||||
egg's [mission] length -- the authentic console mission clock)."""
|
||||
if self.console_proc:
|
||||
self.console_proc.write(b"stop\n")
|
||||
self.end_sent = True
|
||||
self.end_btn.setEnabled(False)
|
||||
self.log.appendPlainText(">> operator END MISSION sent")
|
||||
|
||||
def _console_finished(self):
|
||||
self.log.appendPlainText("== console/relay process exited ==")
|
||||
self.start_btn.setEnabled(True)
|
||||
@@ -612,6 +630,9 @@ class Operator(QMainWindow):
|
||||
% (ST_COLORS[state], tag, state))
|
||||
if self.monitor.launched:
|
||||
head = "LAUNCHED — mission running"
|
||||
if (self.console_proc and not self.end_btn.isEnabled()
|
||||
and not getattr(self, "end_sent", False)):
|
||||
self.end_btn.setEnabled(True)
|
||||
elif self.monitor.ready:
|
||||
head = "ALL PODS READY — press LAUNCH MISSION"
|
||||
elif self.monitor.relay_mode:
|
||||
|
||||
Reference in New Issue
Block a user