extends Node # Signals signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) signal snapshot_received(snapshot: Dictionary) signal atlas_layers_received(response: Dictionary) signal star_map_received(response: Dictionary) # T-949: StarMapResponse signal city_names_received(response: Dictionary) # T-949: CityNamesResponse signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse signal step_canvas_received(response: Dictionary) # T-1182, D-255(c): StepCanvasResponse signal handshake_complete signal handshake_failed(reason: String) # Connection states enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR } # Connection retry state — handles server startup delay (Critical fix #1) const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts # Handshake state (#556) const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds var state: ConnectionState = ConnectionState.DISCONNECTED var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server # Type is TestHarness — untyped to avoid autoload parse-order issue. 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 # Transport layer (non-test mode) var _bridge = null # LocalBridge var _server = null # ServerProcess var _connect_retries: int = 0 var _retry_timer: float = 0.0 var _handshake_start_usec: int = 0 # T-949: true once any caller has asked for the star map. The Atlas/economics # screens can call request_star_map() before the bridge finishes its # handshake — the request is remembered and replayed automatically the # moment _set_state reaches CONNECTED, instead of silently going nowhere. var _star_map_wanted: bool = false var _test_tick: int: get: return harness.tick if harness else 0 set(v): if harness: harness.tick = v var _test_player_pos: Vector2i: get: return harness.player_pos if harness else Vector2i.ZERO set(v): if harness: harness.player_pos = v var _test_facing: String: get: return harness.facing if harness else "North" set(v): if harness: harness.facing = v var _test_in_dialogue: bool: get: return harness.in_dialogue if harness else false set(v): if harness: harness.in_dialogue = v var _test_gauntlet_mode: bool: get: return harness.gauntlet_mode if harness else false set(v): if harness: harness.gauntlet_mode = v var _test_npc_relationship: String: get: return harness.npc_relationship if harness else "Unknown" set(v): if harness: harness.npc_relationship = v var _test_input_queue: Array: get: return harness.input_queue if harness else [] func _ready() -> void: if test_mode: harness = load("res://scripts/protocol/test_harness.gd").new() print("SimBridge: Running in test mode (dynamic snapshot)") # -- Test mode proxy API (backward compat for 13+ test files) ------------------ func reset_test_state() -> void: if harness: harness.reset() # T-949: don't leak a star-map request across tests (autoload state). _star_map_wanted = false # SystemIndex's static cache is the same cross-suite leak class (PR #176 # review H2/T1) — reset it here so the dozens of suites already calling # SimBridge.reset_test_state() cover both. load() inline per the autoload # parse-order rule (CLAUDE.md). var SI := load("res://ui/implant/widgets/system_index.gd") SI.reset_test_state() func _test_snapshot() -> Dictionary: return harness.snapshot() func _test_has_los(from: Vector2i, to: Vector2i) -> bool: return harness.has_los(from, to) # -- Connection lifecycle ------------------------------------------------------ # Change connection state and emit signal func _set_state(new_state: ConnectionState) -> void: if state != new_state: var old_state = state state = new_state connection_state_changed.emit(old_state, new_state) # T-949: fire (or re-fire, on reconnect) any star-map request that was # asked for before we were connected. if new_state == ConnectionState.CONNECTED and _star_map_wanted: _send_star_map_request() # Connect to simulation server. # In test mode, immediately transitions to CONNECTED. # In live mode, spawns server subprocess and defers TCP connection to _process() # to allow the server time to bind its port. func connect_to_sim() -> void: _set_state(ConnectionState.CONNECTING) if test_mode: _set_state(ConnectionState.CONNECTED) return # Spawn server subprocess if not server_path.is_empty(): var SP := load("res://scripts/protocol/server_process.gd") _server = SP.new() # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876"). # D-085 (#258): pass --game-id so server logs use the same session identifier. var args := ["127.0.0.1:" + str(server_port)] var game_id: String = GameState.current_game_id if not game_id.is_empty(): args.append_array(["--game-id", game_id]) var pid: int = _server.start(server_path, args) if pid <= 0: push_error("SimBridge: failed to start server") _set_state(ConnectionState.ERROR) return # Defer TCP connection to _process() — server needs time to bind its port _connect_retries = 0 _retry_timer = 0.0 _bridge = null # Disconnect from simulation server func disconnect_from_sim() -> void: if _bridge != null: _bridge.disconnect_from_server() _bridge = null if _server != null: _server.stop() _server = null _connect_retries = 0 _set_state(ConnectionState.DISCONNECTED) # Attempt TCP connection. Called from _process() during CONNECTING state. func _try_connect() -> void: var LB := load("res://scripts/protocol/local_bridge.gd") _bridge = LB.new() var err: int = _bridge.connect_to_server("127.0.0.1", server_port) if err != OK: push_warning( ( "SimBridge: TCP connect attempt %d/%d failed: %s" % [_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)] ) ) _bridge = null # Poll transport layer every frame (non-test mode only) func _process(delta: float) -> void: # gdlint:disable=max-returns if test_mode: return # CONNECTING state: retry TCP connection until server is ready if state == ConnectionState.CONNECTING: if _bridge == null: _retry_timer += delta if _retry_timer >= CONNECT_RETRY_INTERVAL or _connect_retries == 0: _retry_timer = 0.0 _connect_retries += 1 if _connect_retries > MAX_CONNECT_RETRIES: push_error( "SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES ) _set_state(ConnectionState.ERROR) return _try_connect() return # Bridge exists — poll for connection completion _bridge.poll() match _bridge.get_status(): StreamPeerTCP.STATUS_CONNECTED: _handshake_start_usec = Time.get_ticks_usec() _set_state(ConnectionState.HANDSHAKING) StreamPeerTCP.STATUS_CONNECTING: pass # Still connecting, wait StreamPeerTCP.STATUS_ERROR: # Connection attempt failed — retry _bridge = null if _connect_retries >= MAX_CONNECT_RETRIES: push_error( "SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES ) _set_state(ConnectionState.ERROR) StreamPeerTCP.STATUS_NONE: _bridge = null # Reset and retry return # HANDSHAKING state: read first framed message, validate HandshakeMessage (#556) if state == ConnectionState.HANDSHAKING: if _bridge == null: _set_state(ConnectionState.ERROR) return _bridge.poll() # Check connection dropped during handshake var bridge_status: int = _bridge.get_status() if ( bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE ): var reason := "Connection dropped during handshake" push_error("SimBridge: %s" % reason) handshake_failed.emit(reason) _bridge = null _set_state(ConnectionState.ERROR) return # Check timeout if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC: var reason := "Handshake timeout: no message received within 5 seconds" push_error("SimBridge: %s" % reason) handshake_failed.emit(reason) _bridge.disconnect_from_server() _set_state(ConnectionState.ERROR) return # Try to read first message var msg: PackedByteArray = _bridge.poll_message() if msg.is_empty(): return # Not ready yet, continue polling # Decode HandshakeMessage — D-192 (#875): protocol_version field dropped. # Server sends {} or a minimal dict; only structural validity is required. var MP = load("res://addons/messagepack/messagepack.gd") var decoded: Variant = MP.decode(msg) if decoded.status != null or not (decoded.value is Dictionary): var reason := "Handshake decode failed: malformed HandshakeMessage" push_error("SimBridge: %s" % reason) handshake_failed.emit(reason) _bridge.disconnect_from_server() _set_state(ConnectionState.ERROR) return # 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, connection_role ) if startup_bytes.size() > 0: var send_err: int = _bridge.send_message(startup_bytes) if send_err != OK: var reason := "Failed to send startup message: %s" % error_string(send_err) push_error("SimBridge: %s" % reason) handshake_failed.emit(reason) _bridge.disconnect_from_server() _set_state(ConnectionState.ERROR) return else: var reason := "Failed to encode startup message" push_error("SimBridge: %s" % reason) handshake_failed.emit(reason) _bridge.disconnect_from_server() _set_state(ConnectionState.ERROR) return handshake_complete.emit() _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 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( { "tick": 0, "action_name": "RequestAllSettings", } ) ) return if _bridge == null: return _bridge.poll() match _bridge.get_status(): StreamPeerTCP.STATUS_CONNECTED: # Receive: drain all complete messages from the bridge var msg: PackedByteArray = _bridge.poll_message() while msg.size() > 0: receive_bytes(msg) msg = _bridge.poll_message() # Send: batch-encode and flush outbound buffer as one frame (Vec). # Inputs are drained before encoding. On encode failure the inputs are # intentionally dropped — re-queuing would retry the same bad data and # the server tick has already advanced, making stale inputs invalid. var outbound := drain_outbound() if outbound.size() > 0: var encoded := Protocol.encode_player_inputs(outbound) if encoded.size() > 0: var err: int = _bridge.send_message(encoded) if err != OK: push_error("SimBridge: failed to send message: %s" % error_string(err)) else: push_error( "SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size() ) StreamPeerTCP.STATUS_CONNECTING: pass # Should not happen in CONNECTED state StreamPeerTCP.STATUS_ERROR: if state != ConnectionState.ERROR: push_error("SimBridge: TCP connection error") _set_state(ConnectionState.ERROR) StreamPeerTCP.STATUS_NONE: if state == ConnectionState.CONNECTED: push_warning("SimBridge: connection lost") _set_state(ConnectionState.DISCONNECTED) # -- Input / snapshot ---------------------------------------------------------- # Send input to simulation server. # player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec". # In test mode, inputs are delegated to the test harness. # In live mode, encoded and buffered for transport. # Returns OK on success, or an error code on failure. func send_input(player_input: Dictionary) -> Error: if state != ConnectionState.CONNECTED: return ERR_CONNECTION_ERROR if test_mode: var action: int = player_input.get("action", -1) var wire_name: String = action_enum_to_wire(action) if not wire_name.is_empty(): if wire_name == "SetFacing": var facing: String = "" var action_data: Variant = player_input.get("action_data") if action_data is Dictionary: facing = str(action_data.get("facing", "")) if not facing.is_empty(): harness.process_facing(facing) else: harness.process_input(wire_name) return OK var action_name := action_enum_to_wire(player_input.get("action", -1)) if action_name.is_empty(): return ERR_INVALID_PARAMETER var tick: int = GameState.current_tick var entry: Dictionary = {"tick": tick, "action_name": action_name} var action_data: Variant = player_input.get("action_data") if action_data != null: entry["action_data"] = action_data _outbound_buffer.append(entry) return OK ## Queue a named PlayerAction by wire string (e.g. "RequestBookmarkCatalog"). ## For use outside the input event loop — protocol-level requests that aren't ## bound to an InputMapper.Action enum value. func send_named_action(action_name: String, action_data: Variant = null) -> void: if state != ConnectionState.CONNECTED: push_warning("SimBridge.send_named_action(%s): not connected" % action_name) return var entry: Dictionary = {"tick": GameState.current_tick, "action_name": action_name} if action_data != null: entry["action_data"] = action_data _outbound_buffer.append(entry) ## Request a body's generation-cascade layers from the server (#960, D-225). ## Live mode only — sends an AtlasLayerRequest frame; the response arrives via the ## atlas_layers_received signal. No-op in test mode (no server connection). ## ## window_center/window_n (T-1138, D-226 T-1124 amendment §1): optional ## windowed district-resolution regional-map query, riding alongside any ## up_to value (the window derivation only needs TerrainAnalysis/BodyParams, ## not a specific whole-body layer to be cached first). Omitted by every ## whole-body-layer caller (show_body()'s existing request), so their wire ## traffic is byte-unchanged. ## ## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the ## zoom-ladder quarter rung — district (0/omitted) stays the default for ## every caller in this codebase today. ## ## window_granularity_v2 (T-1152/T-1153): the R5-redesigned string-tag ## granularity ("Quarter"/"District"/"Region") — the ONLY way to request the ## coarser-than-district Region rung the legacy u32 field cannot express. ## Empty string (omitted) is the default for every caller that doesn't pass ## it, byte-compatible with every pre-T-1152 request. func request_atlas_layers( body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0, window_granularity: int = 0, window_min_wl_m: int = 0, window_granularity_v2: String = "" ) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_atlas_layer_request( body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m, window_granularity_v2 ) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) if err != OK: push_error( ( "SimBridge: failed to send atlas layer request for %s: %s" % [body_id, error_string(err)] ) ) ## Request the Reach-level star map / system list from the server (T-949, ## D-010 — replaces the client's direct star_map_data.json file read). Live ## mode only; the response arrives via the star_map_received signal. Safe to ## call before the handshake completes — the request is remembered ## (_star_map_wanted) and replayed automatically once CONNECTED (see ## _set_state), so callers don't need to poll or retry themselves. func request_star_map() -> void: _star_map_wanted = true if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return _send_star_map_request() func _send_star_map_request() -> void: # Same guard as request_star_map(): _star_map_wanted persists on this # autoload across gdUnit suites, so the replay-on-CONNECTED path in # _set_state() can fire in test mode where _bridge is null — an unguarded # send crashed every later suite that called connect_to_sim() (8 tests, # 2026-07-14). Live mode: _bridge exists before CONNECTED, guard passes. if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_star_map_request() if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) if err != OK: push_error("SimBridge: failed to send star map request: %s" % error_string(err)) ## Request one body's atlas city-name pool from the server (T-949, D-223/ ## D-236). Live mode only — sends a CityNamesRequest frame; the response ## arrives via the city_names_received signal. No-op in test mode. Never ## called for Sol bodies (system GJ-0) — atlas_viewer.gd keeps the legacy ## markers.json geometry read for those (D-236, T-1073). func request_city_names(body_id: String) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_city_names_request(body_id) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) if err != OK: push_error( ( "SimBridge: failed to send city names request for %s: %s" % [body_id, error_string(err)] ) ) ## Request one entity kind's index list from the browser proxy (T-1131/ ## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Index} ## frame; the response arrives via browse_response_received. filter_system_id ## only means anything for kind == "Body" (bodies filter by containing ## system) — pass "" (default) for every other kind or for an unfiltered ## Body index. No "wanted before connect" replay bookkeeping like ## request_star_map(): the browser screens call this on-demand while the app ## is open and connected (a live drill-down request, not a session-scoped ## dataset fetched once at boot), so a request issued before CONNECTED is ## simply not sent — the screen re-requests on its own enter()/refresh path ## the next time it's shown. func request_browse_index(kind: String, filter_system_id: String = "") -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_browse_request(kind, "Index", filter_system_id) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) if err != OK: push_error( "SimBridge: failed to send browse index request for %s: %s" % [kind, error_string(err)] ) ## Request one entity's full detail row from the browser proxy (T-1131/ ## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Detail} ## frame; the response arrives via browse_response_received. entity_id is the ## same "id" field an Index row returned for this kind. func request_browse_detail(kind: String, entity_id: String) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_browse_request(kind, "Detail", entity_id) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) if err != OK: push_error( ( "SimBridge: failed to send browse detail request for %s/%s: %s" % [kind, entity_id, error_string(err)] ) ) ## Request one step-canvas data canvas (T-1182, D-255(c)) — the stepped Atlas ## ladder's per-rung terrain/annotation payload. Live mode only; the response ## arrives via step_canvas_received. `rung` is one of the six bare-string rung ## tags (StepCanvasTransport.RUNG_*); `center`/`extent` are sent unconditionally ## even for Global (the server ignores them for that rung — see ## step_canvas_protocol.gd's own doc). No client-side polling loop here — the ## D-225 poll/cache/enqueue pattern means a Pending response is the caller's ## cue to retry, mirroring request_atlas_layers()'s own "fire and let the ## response routing decide" shape. func request_step_canvas( body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 ) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return var bytes := Protocol.encode_step_canvas_request(body_id, rung, center, extent, min_wl_m) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) if err != OK: push_error( ( "SimBridge: failed to send step canvas request for %s/%s: %s" % [body_id, rung, error_string(err)] ) ) # Poll for snapshot from simulation. # In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: if state != ConnectionState.CONNECTED: return null if test_mode: var snapshot = harness.snapshot() snapshot_received.emit(snapshot) return snapshot if _last_snapshot != null: var snapshot = _last_snapshot _last_snapshot = null snapshot_received.emit(snapshot) return snapshot return null # Called by transport layer when raw bytes arrive from the server. # Latest-wins for positional state (stale frames are worthless), but one-shot # events (monologue, dialogue) are carried forward from overwritten snapshots # so they aren't silently dropped when server ticks faster than client consumes. func receive_bytes(bytes: PackedByteArray) -> void: # Decode once, branch by frame shape (#960, D-225; T-949 adds starmap/ # citynames; T-1131/T-1133 adds browse): all response kinds and # snapshots are msgpack maps, told apart by field. var inbound := Protocol.decode_inbound(bytes) if inbound.kind == "atlas": atlas_layers_received.emit(inbound.value) return if inbound.kind == "starmap": star_map_received.emit(inbound.value) return if inbound.kind == "citynames": city_names_received.emit(inbound.value) return if inbound.kind == "browse": browse_response_received.emit(inbound.value) return if inbound.kind == "step_canvas": step_canvas_received.emit(inbound.value) return if inbound.kind != "snapshot": push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size()) return var snapshot = inbound.value if snapshot == null: push_warning("SimBridge: snapshot decode returned null for %d bytes" % bytes.size()) return if _last_snapshot != null: # Carry forward one-shot events the client hasn't consumed yet. if ( snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null ): snapshot["current_monologue"] = _last_snapshot["current_monologue"] if ( snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null ): snapshot["current_dialogue"] = _last_snapshot["current_dialogue"] # #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through) if ( snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null ): snapshot["dialogue_response"] = _last_snapshot["dialogue_response"] var old_conv_events: Array = _last_snapshot.get("conversation_events", []) if old_conv_events.size() > 0: var new_conv_events: Array = snapshot.get("conversation_events", []) snapshot["conversation_events"] = old_conv_events + new_conv_events var old_conv_ended: Array = _last_snapshot.get("conversation_ended", []) if old_conv_ended.size() > 0: var new_conv_ended: Array = snapshot.get("conversation_ended", []) snapshot["conversation_ended"] = old_conv_ended + new_conv_ended # #554: Carry forward save/load result (one-shot, consumed by main.gd) if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null: snapshot["save_result"] = _last_snapshot["save_result"] # #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot) if ( snapshot.get("settings_response") == null and _last_snapshot.get("settings_response") != null ): snapshot["settings_response"] = _last_snapshot["settings_response"] # #872: Carry forward bookmark_catalog (one-shot, consumed by main_menu._on_snapshot_received_for_catalog). # Server sends catalog on tick 0 and after RequestBookmarkCatalog. If tick 0 and tick 1 # arrive in the same TCP batch, the inner receive loop overwrites _last_snapshot and the # catalog is silently lost — this carry-forward prevents that race. if ( snapshot.get("bookmark_catalog") == null and _last_snapshot.get("bookmark_catalog") != null ): snapshot["bookmark_catalog"] = _last_snapshot["bookmark_catalog"] _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. func drain_outbound() -> Array[Dictionary]: var inputs = _outbound_buffer.duplicate() _outbound_buffer.clear() return inputs # -- Wire protocol mapping ----------------------------------------------------- # Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction). # OPEN_MENU is client-only — no Rust equivalent, not sent over the wire. static func action_enum_to_wire(action: int) -> String: match action: InputMapper.Action.MOVE_NORTH: return "MoveNorth" InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast" InputMapper.Action.MOVE_EAST: return "MoveEast" InputMapper.Action.MOVE_SOUTHEAST: return "MoveSoutheast" InputMapper.Action.MOVE_SOUTH: return "MoveSouth" InputMapper.Action.MOVE_SOUTHWEST: return "MoveSouthwest" InputMapper.Action.MOVE_WEST: return "MoveWest" InputMapper.Action.MOVE_NORTHWEST: return "MoveNorthwest" InputMapper.Action.INTERACT: return "Interact" InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode" InputMapper.Action.PAUSE: return "Pause" InputMapper.Action.UNPAUSE: return "Unpause" InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp" InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown" InputMapper.Action.OPEN_MENU: return "" # Client-only action, not part of wire protocol InputMapper.Action.BUG_REPORT: return "" # Client-only action (#495), not part of wire protocol InputMapper.Action.SET_FACING: return "SetFacing" # D-054: facing octant update (no movement) InputMapper.Action.TELEPORT_HUB: return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel) InputMapper.Action.SAVE_GAME: return "SaveGame" # #554: F5 quicksave (D-085) InputMapper.Action.LOAD_GAME: return "LoadGame" # #554: F6 quickload (D-085) InputMapper.Action.DEBUG_COMMAND: return "DebugCommand" # #581: debug console command dispatch InputMapper.Action.CHANGE_SETTINGS: return "ChangeSettings" # #646: persist setting to server SQLite (D-138) InputMapper.Action.REQUEST_ALL_SETTINGS: return "RequestAllSettings" # #646: unit variant — server sends full settings dump InputMapper.Action.DELETE_SETTING: return "DeleteSetting" # #646: struct variant — delete setting by key _: push_warning("SimBridge: unknown action enum %s" % action) return ""