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>
126 lines
4.3 KiB
GDScript
126 lines
4.3 KiB
GDScript
extends Node
|
|
## Lazy registry of installed implant apps (#844, D-191).
|
|
## Autoload — scans apps/*/app.tres on first get_manifests() call.
|
|
## Does NOT reference ImplantAppManifest or ImplantApp class_names at load time
|
|
## (autoload parse-order rule; see CLAUDE.md).
|
|
|
|
const CURRENT_SCHEMA_VERSION := 1
|
|
const _MODE_MAP: Dictionary = {"gameplay": 0, "insert": 1, "fullscreen": 2}
|
|
|
|
var _manifests: Array = [] # Array[ImplantAppManifest]
|
|
var _resolved_modes: Dictionary = {} # app_path -> HudGroups.Mode int
|
|
var _instances: Dictionary = {} # app_path -> ImplantApp
|
|
var _scanned: bool = false
|
|
|
|
|
|
func get_manifests() -> Array:
|
|
if not _scanned:
|
|
_scan()
|
|
return _manifests
|
|
|
|
|
|
func get_resolved_mode(app_path: String) -> int:
|
|
if not _scanned:
|
|
_scan()
|
|
return _resolved_modes.get(app_path, HudGroups.Mode.FULLSCREEN)
|
|
|
|
|
|
## Instantiate all registered apps whose manifest declares a scene_path and add
|
|
## them as children of parent. Manifests without scene_path are metadata-only
|
|
## and are silently skipped. Called from hud.gd._ready() in the normal game
|
|
## (standalone=false, default — every app is hosted, the flag below is
|
|
## irrelevant) and from atlas_standalone.gd (standalone=true, D-254 §3
|
|
## amendment) — in the companion, manifests with available_in_companion=false
|
|
## are skipped entirely (not instantiated, so never reachable by app-path).
|
|
func instantiate_all(parent: Node, standalone: bool = false) -> void:
|
|
for m in get_manifests():
|
|
if standalone and not m.available_in_companion:
|
|
continue
|
|
var scene_path: String = m.scene_path
|
|
if scene_path.is_empty():
|
|
continue
|
|
if not ResourceLoader.exists(scene_path):
|
|
push_warning(
|
|
"ImplantRegistry: scene_path not found for %s: %s" % [m.app_path, scene_path]
|
|
)
|
|
continue
|
|
var packed = load(scene_path)
|
|
if not (packed is PackedScene):
|
|
push_warning("ImplantRegistry: %s is not a PackedScene" % scene_path)
|
|
continue
|
|
var instance = packed.instantiate()
|
|
_instances[m.app_path] = instance
|
|
parent.add_child(instance)
|
|
|
|
|
|
## Return the live ImplantApp instance for app_path, or null if not instantiated.
|
|
func get_app_instance(app_path: String): # returns ImplantApp
|
|
return _instances.get(app_path, null)
|
|
|
|
|
|
func _scan() -> void:
|
|
_scanned = true
|
|
_manifests.clear()
|
|
_resolved_modes.clear()
|
|
var key_owners: Dictionary = {} # default_key -> app_path (collision detection)
|
|
var dir := DirAccess.open("res://ui/implant/apps")
|
|
if dir == null:
|
|
push_warning("ImplantRegistry: could not open res://ui/implant/apps")
|
|
return
|
|
dir.list_dir_begin()
|
|
var entry := dir.get_next()
|
|
while not entry.is_empty():
|
|
if dir.current_is_dir() and not entry.begins_with("."):
|
|
var tres_path: String = "res://ui/implant/apps/%s/app.tres" % entry
|
|
if ResourceLoader.exists(tres_path):
|
|
var m = load(tres_path)
|
|
if not _is_valid_manifest(m):
|
|
push_warning("ImplantRegistry: invalid or missing app_path in %s" % tres_path)
|
|
else:
|
|
var app_path: String = m.app_path
|
|
var mode_str: String = m.default_mode
|
|
var default_key: int = m.default_key
|
|
var schema_ver: int = m.schema_version
|
|
# Schema version check — best-effort in both directions
|
|
if schema_ver < CURRENT_SCHEMA_VERSION:
|
|
print_verbose(
|
|
"ImplantRegistry: backfilled manifest from v%d at %s" % [
|
|
schema_ver, tres_path
|
|
]
|
|
)
|
|
elif schema_ver > CURRENT_SCHEMA_VERSION:
|
|
push_warning(
|
|
"ImplantRegistry: manifest at %s declares schema_version %d; this build supports v%d; proceeding best-effort" % [
|
|
tres_path, schema_ver, CURRENT_SCHEMA_VERSION
|
|
]
|
|
)
|
|
if not _MODE_MAP.has(mode_str):
|
|
push_warning(
|
|
"ImplantRegistry: invalid default_mode \"%s\" in %s — skipping" % [
|
|
mode_str, tres_path
|
|
]
|
|
)
|
|
elif default_key >= 0 and key_owners.has(default_key):
|
|
push_warning(
|
|
"ImplantRegistry: key binding collision — key %d already bound to %s, skipping %s" % [
|
|
default_key, key_owners[default_key], app_path
|
|
]
|
|
)
|
|
else:
|
|
if default_key >= 0:
|
|
key_owners[default_key] = app_path
|
|
_resolved_modes[app_path] = _MODE_MAP[mode_str]
|
|
_manifests.append(m)
|
|
entry = dir.get_next()
|
|
dir.list_dir_end()
|
|
|
|
|
|
func _is_valid_manifest(m: Variant) -> bool:
|
|
if m == null:
|
|
return false
|
|
if not (m is Resource):
|
|
return false
|
|
if not "app_path" in m:
|
|
return false
|
|
return not (m.app_path as String).is_empty()
|