From 6dfaf2831f43e42dcd5e82dce6c89841110c027a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 20:59:34 +0100 Subject: [PATCH] feat(client): add LocalBridge TCP transport and ServerProcess manager LocalBridge wraps StreamPeerTCP with 4-byte big-endian length-prefix framing matching server/src/bridge/framing.rs. ServerProcess manages the Rust server as a subprocess via OS.create_process(). Together they form the D-020 IPC transport layer for ticket #79. Co-Authored-By: Claude Opus 4.6 --- client/scripts/protocol/local_bridge.gd | 143 ++++++++++++++++++ client/scripts/protocol/local_bridge.gd.uid | 1 + client/scripts/protocol/server_process.gd | 40 +++++ client/scripts/protocol/server_process.gd.uid | 1 + 4 files changed, 185 insertions(+) create mode 100644 client/scripts/protocol/local_bridge.gd create mode 100644 client/scripts/protocol/local_bridge.gd.uid create mode 100644 client/scripts/protocol/server_process.gd create mode 100644 client/scripts/protocol/server_process.gd.uid diff --git a/client/scripts/protocol/local_bridge.gd b/client/scripts/protocol/local_bridge.gd new file mode 100644 index 000000000..cc5cc3844 --- /dev/null +++ b/client/scripts/protocol/local_bridge.gd @@ -0,0 +1,143 @@ +class_name LocalBridge +## TCP transport with length-prefixed framing for IPC (D-020). +## +## Wraps StreamPeerTCP with 4-byte big-endian length-prefix framing +## matching server/src/bridge/framing.rs. + +const MAX_MESSAGE_SIZE: int = 16 * 1024 * 1024 # 16 MB, matching server + +var _stream: StreamPeerTCP +var _read_buffer: PackedByteArray = PackedByteArray() +var _pending_length: int = -1 # -1 = awaiting header, >= 0 = awaiting payload + + +func _init() -> void: + _stream = StreamPeerTCP.new() + + +## Initiate connection to host:port. Non-blocking — poll get_status() for result. +func connect_to_server(host: String, port: int) -> Error: + return _stream.connect_to_host(host, port) + + +## Poll the TCP stream. Call every frame to drive connection and reads. +func poll() -> void: + _stream.poll() + + +## Current TCP connection status. +func get_status() -> StreamPeerTCP.Status: + return _stream.get_status() + + +## Whether the TCP stream is in the connected state. +func is_connected_to_server() -> bool: + return _stream.get_status() == StreamPeerTCP.STATUS_CONNECTED + + +## Send a framed message: [4-byte BE length][payload]. +func send_message(payload: PackedByteArray) -> Error: + if not is_connected_to_server(): + return ERR_CONNECTION_ERROR + + var len := payload.size() + if len > MAX_MESSAGE_SIZE: + push_error("LocalBridge: message too large: %d bytes (max %d)" % [len, MAX_MESSAGE_SIZE]) + return ERR_PARAMETER_RANGE_ERROR + + # 4-byte big-endian length header + var header := PackedByteArray() + header.resize(4) + header[0] = (len >> 24) & 0xFF + header[1] = (len >> 16) & 0xFF + header[2] = (len >> 8) & 0xFF + header[3] = len & 0xFF + + var err := _stream.put_data(header) + if err != OK: + return err + return _stream.put_data(payload) + + +## Non-blocking poll for a complete framed message. +## Returns the payload if a complete message is available, empty array otherwise. +## Call in a loop until empty to drain all buffered messages. +func poll_message() -> PackedByteArray: + if not is_connected_to_server(): + return PackedByteArray() + + # Read any available bytes into the buffer + var available := _stream.get_available_bytes() + if available > 0: + var result := _stream.get_data(available) + if result[0] != OK: + return PackedByteArray() + _read_buffer.append_array(result[1]) + + return _try_extract_message() + + +## Try to extract a complete message from the read buffer. +func _try_extract_message() -> PackedByteArray: + # Phase 1: read 4-byte header if we don't have a pending length + if _pending_length < 0: + if _read_buffer.size() < 4: + return PackedByteArray() + _pending_length = (_read_buffer[0] << 24) | (_read_buffer[1] << 16) | \ + (_read_buffer[2] << 8) | _read_buffer[3] + _read_buffer = _read_buffer.slice(4) + + if _pending_length > MAX_MESSAGE_SIZE: + push_error("LocalBridge: incoming message too large: %d bytes (max %d)" % [_pending_length, MAX_MESSAGE_SIZE]) + _pending_length = -1 + _read_buffer.clear() + return PackedByteArray() + + # Phase 2: read payload + if _read_buffer.size() < _pending_length: + return PackedByteArray() + + var payload := _read_buffer.slice(0, _pending_length) + _read_buffer = _read_buffer.slice(_pending_length) + _pending_length = -1 + return payload + + +## Close the connection and reset read state. +func disconnect_from_server() -> void: + _stream.disconnect_from_host() + _read_buffer.clear() + _pending_length = -1 + + +# -- Static helpers for framing (used in tests without a live TCP connection) -- + +## Encode a payload into a framed byte array: [4-byte BE length][payload]. +static func frame_encode(payload: PackedByteArray) -> PackedByteArray: + var len := payload.size() + var frame := PackedByteArray() + frame.resize(4 + len) + frame[0] = (len >> 24) & 0xFF + frame[1] = (len >> 16) & 0xFF + frame[2] = (len >> 8) & 0xFF + frame[3] = len & 0xFF + for i in range(len): + frame[4 + i] = payload[i] + return frame + + +## Decode the first framed message from raw bytes. +## Returns { "payload": PackedByteArray, "remainder": PackedByteArray } or null if incomplete. +static func frame_decode(data: PackedByteArray) -> Variant: + if data.size() < 4: + return null + var len := (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3] + if len > MAX_MESSAGE_SIZE: + push_error("LocalBridge: frame too large: %d bytes" % len) + return null + if data.size() < 4 + len: + return null + return { + "payload": data.slice(4, 4 + len), + "remainder": data.slice(4 + len), + } diff --git a/client/scripts/protocol/local_bridge.gd.uid b/client/scripts/protocol/local_bridge.gd.uid new file mode 100644 index 000000000..870813ae8 --- /dev/null +++ b/client/scripts/protocol/local_bridge.gd.uid @@ -0,0 +1 @@ +uid://7p4xi1hp3cpi diff --git a/client/scripts/protocol/server_process.gd b/client/scripts/protocol/server_process.gd new file mode 100644 index 000000000..b2db65050 --- /dev/null +++ b/client/scripts/protocol/server_process.gd @@ -0,0 +1,40 @@ +class_name ServerProcess +## Manages the Rust simulation server as a subprocess (D-020). + +var _pid: int = -1 + + +## Start the server process. Returns the PID, or -1 on failure. +func start(server_path: String, args: Array = []) -> int: + if _pid > 0 and is_alive(): + push_warning("ServerProcess: server already running (pid %d)" % _pid) + return _pid + _pid = OS.create_process(server_path, args) + if _pid <= 0: + push_error("ServerProcess: failed to start server at %s" % server_path) + return _pid + + +## Check if the server process is still running. +func is_alive() -> bool: + if _pid <= 0: + return false + return OS.is_process_running(_pid) + + +## Stop the server process. +func stop() -> void: + if _pid > 0 and is_alive(): + OS.kill(_pid) + _pid = -1 + + +## Get the server PID (-1 if not started). +func get_pid() -> int: + return _pid + + +## Clean up on destruction — kill the server if still running. +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + stop() diff --git a/client/scripts/protocol/server_process.gd.uid b/client/scripts/protocol/server_process.gd.uid new file mode 100644 index 000000000..e71cac953 --- /dev/null +++ b/client/scripts/protocol/server_process.gd.uid @@ -0,0 +1 @@ +uid://bw2kckfw8jmwl