Files
CosmicClash/Game/scripts/net_codec.gd
T
Josh Creek 4533da34e0 feat(multiplayer): Phase 1 transport, connection, and lobby
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
2026-08-20 08:18:59 +01:00

296 lines
11 KiB
GDScript

class_name NetCodec
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
# no autoload state — so they're testable head-on by tests/test_runner.tscn
# without a live connection.
#
# Referenced from elsewhere via preload(), not the bare class_name, per the
# same global-script-class-cache caveat documented in tests/test_case.gd and
# sim_constants.gd.
const SimConstants = preload("res://scripts/sim_constants.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
const NetBodyState = preload("res://scripts/net_body_state.gd")
# --- Protocol ---
const PROTOCOL_VERSION := 1
const TICK_HZ: int = SimConstants.TICK_HZ
# --- Channels (logical intent; NetworkManager may need to offset these on
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
const CHANNEL_CONTROL := 0
const CHANNEL_INPUT := 1
const CHANNEL_SNAPSHOT := 2
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
# --- Input packet (§2.3) ---
const MAX_REDUNDANCY := 4
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
const INPUT_HEADER_SIZE := 12
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
const INPUT_FLAG_TURBO := 1 << 0
# --- Snapshot packet (§2.4) ---
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
const SNAPSHOT_BODY_HEADER_SIZE := 8
const SNAPSHOT_BODY_SIZE := 22
const BODY_FLAG_FROZEN := 1 << 0
const BODY_FLAG_TURBO := 1 << 1
const BODY_FLAG_THRUST_Z_SHIFT := 2
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
const BODY_FLAG_STALLED := 1 << 5
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
# constants these are sized against) ---
const POS_RANGE := 64.0 # metres, ±
const VEL_RANGE := 64.0 # m/s, ±
const QUAT_COMPONENT_RANGE := 1.0
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
const I16_MAX := 32767
const I8_MAX := 127
const THRUST_Z_BIN_MAX := 7 # 3 bits
# ============================================================
# Quantisers — pure, reusable, independently testable.
# ============================================================
static func quantize_i16(value: float, range_max: float) -> int:
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
static func dequantize_i16(raw: int, range_max: float) -> float:
return (float(raw) / I16_MAX) * range_max
static func quantize_i8(value: float, range_max: float) -> int:
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
static func dequantize_i8(raw: int, range_max: float) -> float:
return (float(raw) / I8_MAX) * range_max
static func quantize_thrust_z_bin(thrust_z: float) -> int:
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
static func dequantize_thrust_z_bin(bin_value: int) -> float:
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
static func type_version_byte(type: PacketType) -> int:
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
static func packet_type_of(type_version: int) -> int:
return (type_version >> 4) & 0x0F
static func protocol_version_of(type_version: int) -> int:
return type_version & 0x0F
# ============================================================
# Input packet — client -> server, channel 1 (§2.3)
# ============================================================
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
var buf := StreamPeerBuffer.new()
buf.put_u8(type_version_byte(PacketType.INPUT))
buf.put_u32(seq)
buf.put_u8(count)
buf.put_u32(ack_snapshot_tick)
buf.put_u16(client_send_ms & 0xFFFF)
for i in count:
var action: ShipAction = actions[i]
buf.put_8(quantize_i8(action.thrust.x, 1.0))
buf.put_8(quantize_i8(action.thrust.y, 1.0))
buf.put_8(quantize_i8(action.thrust.z, 1.0))
buf.put_8(quantize_i8(action.rotation.x, 1.0))
buf.put_8(quantize_i8(action.rotation.y, 1.0))
buf.put_8(quantize_i8(action.rotation.z, 1.0))
var flags := 0
if action.turbo:
flags |= INPUT_FLAG_TURBO
buf.put_u8(flags)
return buf.data_array
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
# client_send_ms, actions (Array[ShipAction], newest first).
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
var type_version := buf.get_u8()
var seq := buf.get_u32()
var count := buf.get_u8()
var ack_snapshot_tick := buf.get_u32()
var client_send_ms := buf.get_u16()
var actions: Array[ShipAction] = []
for i in count:
var a := ShipAction.new()
a.thrust = Vector3(
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0)
)
a.rotation = Vector3(
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0)
)
var flags := buf.get_u8()
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
actions.append(a)
return {
"type_version": type_version,
"seq": seq,
"count": count,
"ack_snapshot_tick": ack_snapshot_tick,
"client_send_ms": client_send_ms,
"actions": actions,
}
# ============================================================
# Snapshot packet — server -> client, channel 2 (§2.4)
# ============================================================
# Shared across every peer this tick — build once, reuse (§2.4's stated
# intent). Returns type_version + server_tick + match_state + reset_gen +
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
buf.put_u32(server_tick)
buf.put_u8(match_state & 0xFF)
buf.put_u8(reset_gen & 0xFF)
buf.put_u8(bodies.size())
for body in bodies:
var b: NetBodyState = body
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
var flags := 0
if b.frozen:
flags |= BODY_FLAG_FROZEN
if b.turbo:
flags |= BODY_FLAG_TURBO
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
if b.stalled:
flags |= BODY_FLAG_STALLED
if b.rotation.w < 0.0:
flags |= BODY_FLAG_QUAT_W_SIGN
buf.put_u8(flags)
return buf.data_array
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u32(last_input_seq)
buf.put_8(clampi(input_buffer_depth, -128, 127))
buf.put_u16(echo_client_send_ms & 0xFFFF)
return buf.data_array
# Convenience: one full per-client packet = per-client header + shared body segment.
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
var out := PackedByteArray()
out.append_array(header)
out.append_array(body_segment)
return out
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
var last_input_seq := buf.get_u32()
var input_buffer_depth := buf.get_8()
var echo_client_send_ms := buf.get_u16()
var type_version := buf.get_u8()
var server_tick := buf.get_u32()
var match_state := buf.get_u8()
var reset_gen := buf.get_u8()
var body_count := buf.get_u8()
var bodies: Array[NetBodyState] = []
for i in body_count:
var b := NetBodyState.new()
b.position = Vector3(
dequantize_i16(buf.get_16(), POS_RANGE),
dequantize_i16(buf.get_16(), POS_RANGE),
dequantize_i16(buf.get_16(), POS_RANGE)
)
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
b.linear_velocity = Vector3(
dequantize_i16(buf.get_16(), VEL_RANGE),
dequantize_i16(buf.get_16(), VEL_RANGE),
dequantize_i16(buf.get_16(), VEL_RANGE)
)
# avel_range is unknown to the codec at this point (it isn't on the
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
# the caller, which knows this slot's body kind, rescale if it's the
# ball's slot. Storing the raw i8 would avoid this, but every other
# field in this struct is already physical units; consistency wins.
b.angular_velocity = Vector3(
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
)
var flags := buf.get_u8()
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
b.turbo = (flags & BODY_FLAG_TURBO) != 0
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
b.thrust_z = dequantize_thrust_z_bin(bin_value)
b.stalled = (flags & BODY_FLAG_STALLED) != 0
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
var w := sqrt(maxf(w_sq, 0.0))
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
w = -w
b.rotation = Quaternion(qx, qy, qz, w)
bodies.append(b)
return {
"last_input_seq": last_input_seq,
"input_buffer_depth": input_buffer_depth,
"echo_client_send_ms": echo_client_send_ms,
"type_version": type_version,
"server_tick": server_tick,
"match_state": match_state,
"reset_gen": reset_gen,
"bodies": bodies,
}
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
# assumption unpack_snapshot() decoded it with to the range it was actually
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
# immediately after unpack_snapshot(), using slot order to know which.
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
body.avel_range = actual_range
return
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
body.avel_range = actual_range