feat(client): T-1132 standalone Atlas companion shell + make atlas (D-254 SS3)

New atlas_standalone scene/script: attach-or-spawn boot (one REAL
connect_to_sim attempt at SR_PORT-or-9876 — a separate throwaway TCP
probe was proven by live run to kill a pre-accept-loop server via
broken handshake pipe; never abandon a connected socket), else spawn
--port 0 via new ServerProcess.start_with_pipe + LISTENING:{port}
stdout parse, retry against the resolved port. Reader role wired end
to end: protocol.encode_startup_message optional role param (empty
omits the wire key — byte-identical for all existing callers),
sim_bridge.connection_role suppresses the post-handshake
RequestAllSettings auto-send, hud_groups skips AutoPause/AutoResume
sends for readers (all three would otherwise burn Reader violation
strikes per the T-1130 matrix — endorsed by Oscar).

Generic implant host per D-254 SS3: the shell instantiates ALL
registered implant apps; implant_app_manifest gains
available_in_companion (opt-out, default true) and
implant_registry.instantiate_all a standalone filter param (default
preserves hud.gd behavior byte-identically). Boot order is
instantiate_all THEN open_app (reverse renders a permanently black
window — app_changed fires with no listener; matches hud.gd's order).
Owned-server lifecycle: _exit_tree stops a spawned child, attached
servers survive companion close. Known engine limitation documented:
raw SIGTERM bypasses all Godot notifications and orphans a spawned
server; WM close paths verified clean.

Live-verified: spawn-mode (301 systems rendered from systems.db over
the wire), attach-mode, two simultaneous readers, clean shutdown with
zero orphan processes. 14 new gdUnit tests (port/LISTENING parsing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 09:09:09 +02:00
co-authored by Claude Fable 5
parent ddc3d39d09
commit cffe760d36
10 changed files with 538 additions and 5 deletions
+269
View File
@@ -0,0 +1,269 @@
extends Control
## Standalone Atlas companion app entry point (D-254, T-1132, `make atlas`).
##
## Boots the SAME implant scene tree used in-game, skipping the player
## entirely — no character, no main.tscn, no gameplay HUD. Auto-attaches to
## an already-running game (read-only) or spawns its own server against the
## real systems.db world (no --test-mode). Modeled on visual_capture.gd's
## live-mode boot shape (D-254 §3), but a real scene script (normal _ready()),
## not a SceneTree-extending offscreen test harness.
##
## D-254 §3 amendment (2026-07-17, generic implant host): this scene hosts
## EVERY registered implant app (ImplantRegistry.instantiate_all(self, true) —
## the standalone=true filter drops apps whose manifest sets
## available_in_companion=false), fronting the Atlas (implant/map) as the
## default open app. Future implant apps extend the companion with zero
## changes here.
##
## Boot sequence (_ready → _boot, deferred so the node is in-tree first):
## 1. Discovery/attach: SimBridge.connect_to_sim() against SR_PORT-or-9876,
## polled for up to ATTACH_CONNECT_TIMEOUT_S. On CONNECTED, attach
## succeeded — skip straight to step 4. On ERROR (nothing listening),
## fall through to spawn (own child server, --port 0, parse
## LISTENING:{port} from stdout — server_process.gd's start_with_pipe(),
## never --test-mode: real systems.db world), then repeat the connect
## against the newly-resolved port.
##
## NOT a separate throwaway TCP probe-then-reconnect (an earlier version
## of this script had one, and a live run surfaced why that design is
## actively harmful against today's server: main.rs's FIRST connection is
## still a single blocking listener.accept() (server/src/main.rs, D-254
## §2/T-1130's own comment there) — a probe that connects, sees
## STATUS_CONNECTED, and disconnects has just consumed that one accept()
## slot and abandoned it mid-handshake. The server's very next line,
## bridge.send_handshake(), then hits "Broken pipe" writing to the dead
## probe socket and the WHOLE SERVER PROCESS EXITS (main.rs's
## .unwrap_or_else(|e| { ...; std::process::exit(1) }) on that call) —
## confirmed by a live repro against a real already-running server: the
## probe's own attach check killed the very server session it was
## checking on, and the companion's own real connection attempt right
## after then failed too, since there was nothing left to connect to.
## The fix: never abandon a connection that reached STATUS_CONNECTED.
## SimBridge.connect_to_sim() IS that one safe connection attempt (it
## already TCP-retries internally, see MAX_CONNECT_RETRIES/
## CONNECT_RETRY_INTERVAL in sim_bridge.gd) — this script drives it
## directly instead of pre-flighting with a disposable socket.
## 2. Configure SimBridge for the resolved port; connection_role = "Reader"
## (D-254 §2) so the StartupMessage carries role: Reader.
## 3. connect_to_sim(); poll SimBridge.state until CONNECTED (or ERROR).
## 4. HudGroups.open_app("implant/map") — the default front app.
## 5. ImplantRegistry.instantiate_all(self, true) populates every
## available_in_companion app.
## 6. Intercept HudGroups.app_changed: when the Atlas's own KEY_M/KEY_ESCAPE
## handling (atlas_app.gd) calls HudGroups.close_app() from the "reach"
## screen, this scene re-opens implant/map instead of falling into a
## nonexistent gameplay layer (D-254 §3 recommended option (a) — does not
## touch atlas_app.gd).
const DEFAULT_PORT: int = 9876
## D-254 §1 names ~500ms for the attach check; SimBridge's own internal TCP
## retry loop (MAX_CONNECT_RETRIES=20 * CONNECT_RETRY_INTERVAL=0.1s, sim_bridge.gd)
## is ~2s end-to-end before it reports ConnectionState.ERROR. Since this
## script drives connect_to_sim() directly (no separate pre-probe — see the
## header note above), the real wait for "nothing is listening" is bounded by
## SimBridge's own retry budget, not this constant; ATTACH_CONNECT_TIMEOUT_S
## is a slightly-generous outer deadline (SimBridge's ~2s + one frame margin)
## so this script's own poll loop can never out-wait SimBridge's, not a
## separate tunable that changes the actual ECONNREFUSED-detection speed.
const ATTACH_CONNECT_TIMEOUT_S: float = 2.5
const CONNECT_POLL_TIMEOUT_S: float = 10.0
const SPAWN_READY_TIMEOUT_S: float = 15.0
const WINDOW_TITLE: String = "The Settled Reach — Atlas"
const FRONT_APP: String = "implant/map"
## T-1134 seam: spawn-mode seed is the server's own default (SimRng default
## seed=0, server/src/main.rs) — no seed picker in v1. Leaving this constant
## (rather than inlining "no seed sent") makes the future seam obvious: T-1134
## replaces this with a startup screen that sets SimBridge.world_seed_override
## (or equivalent) before _boot() reaches step 2. StartupMessage.world_seed is
## ALWAYS sent (server/src/bridge/types.rs: field is not optional on the wire)
## — GameState.world_seed's own default (0) is what ships until T-1134 adds a
## picker; this constant exists purely as the documented seam marker.
const SPAWN_MODE_SEED_IS_SERVER_DEFAULT: bool = true
var _server_process: Variant = null # ServerProcess — owned here for spawn-mode only
var _resolved_port: int = DEFAULT_PORT
var _spawned: bool = false # true once this scene has spawned its own server
func _ready() -> void:
DisplayServer.window_set_title(WINDOW_TITLE)
HudGroups.app_changed.connect(_on_hud_app_changed)
_boot.call_deferred()
func _exit_tree() -> void:
_stop_spawned_server()
## D-254 §3 step 6 (recommended option (a)): the Atlas's own close handling
## (atlas_app.gd _handle_key, KEY_M/KEY_ESCAPE from "reach") calls
## HudGroups.close_app() unconditionally — it has no notion of "there is no
## gameplay layer to fall back to". This intercepts that transition and
## re-opens the Atlas immediately, so from the player's perspective M/Escape
## on the top-level Atlas screen is a no-op in the companion (there is nothing
## else to show). atlas_app.gd is never modified.
func _on_hud_app_changed(app_path: String, mode: int) -> void:
if app_path == FRONT_APP and mode == HudGroups.Mode.GAMEPLAY:
HudGroups.open_app(FRONT_APP)
func _boot() -> void:
_resolved_port = _attach_port()
SimBridge.server_path = "" # atlas_standalone owns any child process, not SimBridge
SimBridge.server_port = _resolved_port
SimBridge.connection_role = "Reader" # D-254 §2
# Attempt the ONE real attach connection — never a disposable pre-probe
# (see the class doc header for why: an earlier probe-then-reconnect design
# killed the server it was checking on). connect_to_sim() TCP-retries
# internally for ~2s (sim_bridge.gd) before reporting ERROR.
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
SimBridge.connect_to_sim()
var attached := await _wait_for_connected(ATTACH_CONNECT_TIMEOUT_S)
if not attached:
# Nothing answered on the attach port — fall through to spawn. The
# failed attempt above must be fully torn down before spawning: a
# leftover ERROR-state _bridge/_server would otherwise confuse the
# fresh connect_to_sim() call below (disconnect_from_sim() resets
# SimBridge to DISCONNECTED, which is connect_to_sim()'s required
# entry state — see the DISCONNECTED guard above and in main.gd).
SimBridge.disconnect_from_sim()
var spawn_ok := await _spawn_server()
if not spawn_ok:
push_error("atlas_standalone: failed to spawn server — cannot continue")
get_tree().quit(1)
return
SimBridge.server_port = _resolved_port # _spawn_server() updated _resolved_port
SimBridge.connection_role = "Reader"
SimBridge.connect_to_sim()
attached = await _wait_for_connected(CONNECT_POLL_TIMEOUT_S)
if not attached:
push_error("atlas_standalone: connection to spawned server failed or timed out")
get_tree().quit(1)
return
# Re-apply the title here, not just in _ready(): confirmed by direct repro
# that Godot's un-exported debug-run window manager sets the title to
# "{config/name} (DEBUG)" on a LATER frame than _ready() (window-visible/
# focus timing, not measured precisely) — an early-only window_set_title()
# call loses that race and the window silently reverts to the project
# default. By the time the connection completes (at minimum one full TCP
# round-trip, often a spawn+handshake), that race has long resolved.
DisplayServer.window_set_title(WINDOW_TITLE)
# instantiate_all() MUST run before open_app(): ImplantApp._ready()
# (client/ui/implant/implant_app.gd) is what connects HudGroups.app_changed
# -> _internal_app_changed, which is the ONLY thing that flips an app's
# `visible` flag and fires on_open(). open_app() first would emit
# app_changed while the Atlas doesn't exist as a node yet — no listener,
# no visibility, a permanently blank window (found via a live run: the
# window opened, connected, and instantiated both apps correctly, but
# rendered solid black — the exact fingerprint of this ordering bug).
# hud.gd's own _ready() establishes the same order for the in-game case
# (instantiate_all is unconditional there; open_app only ever happens
# later, from player input).
ImplantRegistry.instantiate_all(self, true) # standalone=true: available_in_companion filter
HudGroups.open_app(FRONT_APP)
## Pure function: SR_PORT env override, else DEFAULT_PORT. Matches the
## SR_PORT two-tier discovery visual_capture.gd/locomotion_sandbox.gd already
## use (D-254 §1) — same env var, same fallback. This is the port the ONE
## attach connect_to_sim() call targets (see _boot()) — no separate probe.
static func _attach_port(port_env: String = "") -> int:
var env := port_env if not port_env.is_empty() else OS.get_environment("SR_PORT")
if env.is_empty():
return DEFAULT_PORT
if not env.is_valid_int():
push_warning("atlas_standalone: SR_PORT='%s' is not a valid int — using default" % env)
return DEFAULT_PORT
return int(env)
## D-254 §1/§3: spawn-mode lifecycle — tests/run-visual's --port 0 +
## LISTENING:{port} precedent, WITHOUT --test-mode (real systems.db world,
## not Gauntlet fixtures). Ownership: this scene owns the ServerProcess
## instance directly (not SimBridge's built-in spawn path, which has no
## stdout access and therefore can't resolve an OS-assigned port) — the same
## OS.kill/NOTIFICATION_PREDELETE safety net server_process.gd already
## implements, reused via start_with_pipe() (added for this ticket; start()'s
## existing OS.create_process contract is untouched for its three existing
## callers). Returns true once LISTENING:{port} is parsed and _resolved_port
## is updated; false on spawn failure or timeout (server never printed the
## signal within SPAWN_READY_TIMEOUT_S).
func _spawn_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_error("atlas_standalone: server binary not found at %s (run `make build-server`)" % server_path)
return false
var SP := load("res://scripts/protocol/server_process.gd")
_server_process = SP.new()
var pid: int = _server_process.start_with_pipe(server_path, ["--port", "0"])
if pid <= 0:
return false
_spawned = true
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while elapsed < SPAWN_READY_TIMEOUT_S:
if not _server_process.is_alive():
push_error("atlas_standalone: spawned server died before printing LISTENING signal")
return false
var line: String = _server_process.read_stdout_line()
if not line.is_empty():
var parsed := _parse_listening_line(line)
if parsed >= 0:
_resolved_port = parsed
return true
await get_tree().process_frame
elapsed += frame_budget
push_error("atlas_standalone: no LISTENING signal from spawned server after %.1fs" % SPAWN_READY_TIMEOUT_S)
return false
## Pure function: parse "LISTENING:{port}" -> port, or -1 if the line doesn't
## match (main.rs's exact signal format — server/src/main.rs, "LISTENING
## signal to stdout"). Split out from _spawn_server()'s polling loop so the
## parse itself is unit-testable without a live process.
static func _parse_listening_line(line: String) -> int:
var stripped := line.strip_edges()
if not stripped.begins_with("LISTENING:"):
return -1
var port_str := stripped.substr("LISTENING:".length())
if not port_str.is_valid_int():
return -1
return int(port_str)
## Wall-clock poll of SimBridge.state until CONNECTED or ERROR (same
## ConnectionState enum/shape as visual_capture.gd's live-mode wait, per
## D-254 §3 step 3 — minus the fixed-frame settle budget a screenshot capture
## needs but a real window does not).
func _wait_for_connected(timeout_s: float) -> bool:
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while elapsed < timeout_s:
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
return true
if SimBridge.state == SimBridge.ConnectionState.ERROR:
return false
await get_tree().process_frame
elapsed += frame_budget
return false
func _server_binary_path() -> String:
var project_dir := ProjectSettings.globalize_path("res://")
return project_dir.path_join("../server/target/debug/settled-reach-server")
func _stop_spawned_server() -> void:
if _spawned and _server_process != null:
_server_process.stop()
_server_process = null
_spawned = false
+13
View File
@@ -60,7 +60,20 @@ func _ready() -> void:
## send_named_action (protocol-level, not bound to an InputMapper.Action
## keybind) — the same mechanism as RequestBookmarkCatalog — since occlusion
## is a UI-state transition, not a physical input.
## D-254 §2: skipped for Reader connections (atlas_standalone.gd's companion
## app) — this travels through the same Vec<PlayerInput> pipeline as
## MoveNorth/Interact, which the Reader permission matrix marks "no". A
## Reader has no character and no pausable gameplay session of its own (the
## standalone scene calls HudGroups.open_app() as its normal boot step, not a
## player occluding their own gameplay), so there is nothing meaningful to
## pause — sending it anyway would just be dropped as a role violation on
## every single companion boot (confirmed via a live run against a real
## server: "Reader connection ... sent 1 disallowed PlayerInput(s) — dropped
## (strike 1/3)" fired from this exact call site the first time the companion
## opened the Atlas fullscreen).
func _on_gameplay_occluded_auto_pause(occluded: bool) -> void:
if SimBridge.connection_role == "Reader":
return
if occluded:
SimBridge.send_named_action("AutoPause")
else:
+15 -1
View File
@@ -25,6 +25,11 @@ var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects
var harness = null # Test simulation (D-020: game logic lives outside production client)
var server_port: int = 9876 # Default matches server's default bind address
var server_path: String = "" # Path to server binary — set before connect_to_sim()
# D-254 §2/§3: ConnectionRole wire value for the next StartupMessage — "" (default)
# omits the "role" key (server defaults to Player, byte-identical to pre-D-254
# behavior). atlas_standalone.gd sets this to "Reader" before connect_to_sim();
# every existing caller (main.gd, locomotion_sandbox.gd, tests) leaves it unset.
var connection_role: String = ""
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -283,8 +288,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
# Server blocks waiting for this before entering the tick loop.
# D-254 §2: connection_role threads "Reader" for the standalone companion;
# every other caller leaves it "" (omitted key, decodes as Player).
var startup_bytes := Protocol.encode_startup_message(
GameState.world_seed, GameState.character_visual_descriptor
GameState.world_seed, GameState.character_visual_descriptor, connection_role
)
if startup_bytes.size() > 0:
var send_err: int = _bridge.send_message(startup_bytes)
@@ -307,6 +314,13 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_set_state(ConnectionState.CONNECTED)
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
# from server SQLite so the client reflects the authoritative persisted state (D-138).
# D-254 §2: this travels as a Vec<PlayerInput> entry (same pipeline as
# MoveNorth/Interact), which the permission matrix marks "no" for Reader —
# a Reader has no settings to hydrate (no character, no per-player state),
# so skip the send rather than have the server log-and-drop it on every
# companion connection once role enforcement lands server-side.
if connection_role == "Reader":
return
(
_outbound_buffer
. append(
+9 -1
View File
@@ -639,14 +639,22 @@ static func _decode_enum_variant(raw) -> Dictionary:
## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng (D-010, D-029).
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
## role: D-254 §2 ConnectionRole wire value — "" (default, every caller before
## D-254) omits the "role" key entirely, matching server StartupMessage::role's
## #[serde(default)] and decoding as ConnectionRole::Player — byte-identical to
## pre-D-254 output. atlas_standalone.gd is the one caller that passes "Reader"
## (unit enum variant, bare string per this file's header wire-format note —
## same encoding as PlayerAction unit variants like "MoveNorth").
static func encode_startup_message(
world_seed: int, character_visual: Variant = null
world_seed: int, character_visual: Variant = null, role: String = ""
) -> PackedByteArray:
var msg := {
"world_seed": world_seed,
}
if character_visual != null and character_visual.has_method("to_dict"):
msg["character_visual_descriptor"] = character_visual.to_dict()
if not role.is_empty():
msg["role"] = role
var result = _mp().encode(msg)
if result.status != null:
push_error("Protocol: startup message encode failed: %s" % result.status)
+40
View File
@@ -2,6 +2,9 @@ class_name ServerProcess
## Manages the Rust simulation server as a subprocess (D-020).
var _pid: int = -1
## Populated only by start_with_pipe() — the stdout FileAccess handle used to
## read the LISTENING:{port} signal (D-254 §1/§3). Null after plain start().
var _stdio: FileAccess = null
## Start the server process. Returns the PID, or -1 on failure.
@@ -19,6 +22,43 @@ func start(server_path: String, args: Array = []) -> int:
return _pid
## Start the server process with its stdout piped back to this process
## (D-254 §1/§3: the companion app's --port 0 spawn-mode, which needs to read
## the LISTENING:{port} signal main.rs prints — see server/src/main.rs). Unlike
## start()/OS.create_process, this uses OS.execute_with_pipe, which does not
## block (the shell precedent, tests/run-visual, achieves the same read via a
## redirected log file + `grep` from a wrapper script; this is the Godot-native
## equivalent for a caller with no shell wrapper). Returns the PID, or -1 on
## failure. Call read_stdout_line() from a per-frame poll to drain the pipe.
func start_with_pipe(server_path: String, args: Array = []) -> int:
if _pid > 0 and is_alive():
push_warning("ServerProcess: server already running (pid %d)" % _pid)
return _pid
if not FileAccess.file_exists(server_path):
push_error("ServerProcess: server binary not found at %s" % server_path)
return -1
var result: Dictionary = OS.execute_with_pipe(server_path, args)
if not result.has("pid") or int(result.get("pid", -1)) <= 0:
push_error("ServerProcess: failed to start server (pipe) at %s" % server_path)
return -1
_pid = int(result["pid"])
_stdio = result.get("stdio", null)
return _pid
## Non-blocking read of one line from the piped server's stdout, or "" if no
## complete line is buffered yet (FileAccess.get_line() on a pipe never
## blocks — it returns immediately with whatever is currently available).
## Only meaningful after start_with_pipe(); returns "" if _stdio was never set
## (plain start()) or the process has exited (stream at EOF).
func read_stdout_line() -> String:
if _stdio == null:
return ""
if _stdio.get_error() != OK:
return ""
return _stdio.get_line()
## Check if the server process is still running.
## NOTE: OS.is_process_running() doesn't distinguish starting/healthy/zombie.
## TODO: Add health check (e.g. TCP readiness probe) when server supports it.