Item 1: while zero tiles have arrived, the viewer draws a centered screen-space 'DERIVING TERRAIN…' label (text_dim role, no new hue), dropping the instant the first tile lands — a cold wait now reads as loading, not broken. New has_any_tile_arrived() predicate (distinct from has_pending_tiles(): both true mid-arrival, tested exactly there). Item 2: legend re-fit root-caused empirically, two plausible fixes disproven by trace before the real one: a manually-positioned Control's size NEVER tracks a shrinking minimum in this parenting shape, and RichTextLabel.fit_content reports degenerate minimums until laid out at real width once — so reset must be DEFERRED and run after refill, not inside clear(). ImplantPanel.reset_to_content_size() (call_deferred), wired into both legend refresh()es. The load-bearing test compares size.y to get_minimum_size().y — a size-to-size comparison passed trivially with both numbers equally stuck (caught on first draft). Item 3: make atlas now builds the RELEASE server and passes SR_SERVER_BIN (a cold DEBUG server delivers zero tiles for >10s on a new body — live-measured — vs 210ms warm; release serves cold in well under a second). atlas_standalone._server_binary_path() honors the env override per the SR_PORT two-tier precedent, debug path unchanged when unset. All fixes revert-verified; nine suites green collateral-checked; gdlint clean.
294 lines
15 KiB
GDScript
294 lines
15 KiB
GDScript
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:
|
|
# Deferred, never synchronous: this handler runs INSIDE close_app()'s
|
|
# app_changed emit, and close_app() continues after the emit and resets
|
|
# _active_app to "" — a synchronous open_app() here gets clobbered,
|
|
# leaving the Atlas visible (open_app already raised its z) but
|
|
# is_app_active() false, which kills atlas_app's input guard: a
|
|
# keyboard soft-lock (PR #183 review, Tyre).
|
|
HudGroups.open_app.call_deferred(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
|
|
|
|
|
|
## Pure function: SR_SERVER_BIN env override, else the debug build path —
|
|
## same two-tier shape as _attach_port()'s SR_PORT (D-254 §1). `make atlas`
|
|
## now builds the RELEASE server and passes SR_SERVER_BIN so a `_spawn_server()`
|
|
## cold-start (this file's own live-repro path — the coordinator's cold-start
|
|
## dossier) gets sub-second first-tile derivation instead of a debug build's
|
|
## multi-second AnalyzeBody. `override_env`/`override_project_dir` exist ONLY
|
|
## for direct-call unit tests (mirrors _attach_port(port_env)'s own param
|
|
## shape) — every real caller uses the zero-arg form. Accepts either an
|
|
## ABSOLUTE path or one relative to the project root (`res://../`), so
|
|
## SR_SERVER_BIN can be given as `server/target/release/settled-reach-server`
|
|
## (the Makefile's own shape) without the caller having to know this script's
|
|
## `res://` layout.
|
|
static func _server_binary_path(override_env: String = "", override_project_dir: String = "") -> String:
|
|
var project_dir := (
|
|
override_project_dir if not override_project_dir.is_empty()
|
|
else ProjectSettings.globalize_path("res://")
|
|
)
|
|
var env := override_env if not override_env.is_empty() else OS.get_environment("SR_SERVER_BIN")
|
|
if not env.is_empty():
|
|
return env if env.is_absolute_path() else project_dir.path_join("../" + env)
|
|
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
|