class_name NetCodec # Wire-format constants, quantisers, and pack/unpack for the two hot-path # packets (§2 of MULTIPLAYER_SPEC.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_SPEC.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