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:
arcattack
2026-07-18 15:17:42 -05:00
co-authored by Claude Fable 5
parent f9f230c62b
commit f57d25f467
3 changed files with 110 additions and 8 deletions
+12
View File
@@ -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
View File
@@ -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:
+21
View File
@@ -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: