Fix 354 gdlint warnings across 65 files: 194 class-definitions-order (reorder declarations), 138 max-line-length (split long lines), 22 code issues (unused args, no-else-return, naming). Update .gdlintrc to exclude addons/ and raise max-public-methods for test files. No logic changes — declaration order, whitespace, and naming only. Ticket: #783 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
161 lines
5.0 KiB
GDScript
161 lines
5.0 KiB
GDScript
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
|
|
var _corrupt: bool = false # Set on unrecoverable framing error
|
|
|
|
|
|
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:
|
|
if _corrupt:
|
|
return StreamPeerTCP.STATUS_ERROR
|
|
return _stream.get_status()
|
|
|
|
|
|
## Whether the TCP stream is in the connected state.
|
|
func is_connected_to_server() -> bool:
|
|
return not _corrupt and _stream.get_status() == StreamPeerTCP.STATUS_CONNECTED
|
|
|
|
|
|
## Send a framed message: [4-byte BE length][payload].
|
|
## Concatenates header and payload into a single put_data() call to avoid partial writes.
|
|
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
|
|
|
|
# Build complete frame: [4-byte BE length][payload]
|
|
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 _stream.put_data(frame)
|
|
|
|
|
|
## 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:
|
|
# Stream is corrupt — we can't find the next valid frame boundary.
|
|
# Disconnect rather than silently discarding valid buffered data.
|
|
push_error(
|
|
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
|
|
_corrupt = true
|
|
_pending_length = -1
|
|
_read_buffer.clear()
|
|
disconnect_from_server()
|
|
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
|
|
|
|
|
|
## Reset corrupt state (for reconnection after disconnect).
|
|
func reset() -> void:
|
|
_corrupt = false
|
|
_read_buffer.clear()
|
|
_pending_length = -1
|
|
|
|
|
|
# -- Static helpers for D-030 Layer 2 testing ----------------------------------
|
|
# These exist for unit tests that verify framing logic without a live TCP
|
|
# connection. Not used in production code paths.
|
|
|
|
## 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),
|
|
}
|