class_name NetCodec extends RefCounted ## Binary encoders for the two server -> client streams. ## ## Snapshots are lossy and unreliable: only what is needed to draw and predict. ## Events are exact and reliable: things a client can never re-derive, above all ## bullet spawns and the despawns caused by a hit. # --- Snapshot --------------------------------------------------------------- ## [param cleared_countdown] is whole seconds until a cleared dungeon returns ## its party, or Protocol.COUNTDOWN_NONE when that does not apply. ## [param for_peer] scopes the snapshot to what that player may know: actors ## beyond SimConfig.ACTOR_INTEREST_RADIUS are omitted entirely, so fog is not ## the only thing hiding them. Pass 0 to encode the whole world (tests, and the ## safe fallback if the observer cannot be found). static func encode_snapshot(world: SimWorld, cleared_countdown: int = Protocol.COUNTDOWN_NONE, for_peer: int = 0) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u32(world.tick) b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE)) var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null var eye := Vector2.ZERO var cull_sq := 0.0 if observer != null: eye = observer.pos cull_sq = SimConfig.ACTOR_INTEREST_RADIUS * SimConfig.ACTOR_INTEREST_RADIUS var visible_players: Array[SimPlayer] = [] for p in world.players.values(): # Always include the observer's own player, whatever the distance # arithmetic says: the client reconciles its prediction against this # record, and dropping it would break its own movement. if observer == null or p.peer_id == for_peer \ or eye.distance_squared_to(p.pos) <= cull_sq: visible_players.append(p) b.put_u8(mini(visible_players.size(), 255)) for p in visible_players: b.put_u32(p.peer_id) b.put_float(p.pos.x) b.put_float(p.pos.y) b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536)) b.put_u16(clampi(p.hp, 0, 65535)) # Sent rather than assumed: max health follows the character's level, so # a HUD bar computed from a constant would be wrong for anyone past # level 1, and wrong for every other player in the party. b.put_u16(clampi(p.max_hp, 1, 65535)) var flags := 0 if p.alive: flags |= Protocol.F_ALIVE if p.escape_ticks > 0: flags |= Protocol.F_ESCAPING if p.spawn_grace > 0: flags |= Protocol.F_SPAWN_GRACE if p.linkdead: flags |= Protocol.F_LINKDEAD b.put_u8(flags) b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255)) # Whole ticks are more than the HUD needs; a byte of tenths of a second # covers the lockout with room to spare. b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255)) # Echoed so the owning client knows how far to rewind when reconciling. b.put_u32(p.last_input_tick) # Static per character, so sending it every snapshot is slightly # wasteful -- but it is four bytes, and the alternative is a separate # message plus the join-ordering bug where someone arrives before it. b.put_u32(p.colour.to_rgba32()) # Experience rides the snapshot rather than waiting for a roster # message: the bar has to move on every kill, and the roster is only # re-sent when the set of characters actually changes. b.put_u32(maxi(p.total_xp, 0)) var live_enemies: Array[SimEnemy] = [] for e in world.enemies.values(): if not e.alive: continue if observer != null and eye.distance_squared_to(e.pos) > cull_sq: continue live_enemies.append(e) b.put_u16(mini(live_enemies.size(), 65535)) for e in live_enemies: b.put_u32(e.id) b.put_float(e.pos.x) b.put_float(e.pos.y) b.put_u16(clampi(e.hp, 0, 65535)) # Radius and visual travel with the snapshot so a client that joins # mid-fight can draw an enemy without any extra handshake. b.put_u8(clampi(roundi(e.def.radius * 2.0), 0, 255)) b.put_u8(clampi(e.def.visual, 0, 255)) var has_boss := world.boss != null and world.boss.alive if has_boss and observer != null \ and eye.distance_squared_to(world.boss.pos) > cull_sq: has_boss = false b.put_u8(1 if has_boss else 0) if has_boss: b.put_u32(world.boss.id) b.put_float(world.boss.pos.x) b.put_float(world.boss.pos.y) b.put_u32(maxi(world.boss.hp, 0)) b.put_u8(clampi(world.boss.phase_index, 0, 255)) # Only the observer's own bag. Nobody needs to see what a party member is # carrying, and not sending it means there is nothing to leak. for i in SimConfig.INVENTORY_SLOTS: var carried := Items.NONE if observer != null and i < observer.inventory.size(): carried = observer.inventory[i] b.put_u8(Items.index_of(carried)) # Ground loot. Player-instanced items are filtered here rather than hidden # in the client: a peer is never told that another player's copy exists, so # a modified client has nothing to reveal. var visible_loot: Array[SimLoot] = [] for l in world.loot.values(): if observer != null: if not l.visible_to(for_peer): continue if eye.distance_squared_to(l.pos) > cull_sq: continue visible_loot.append(l) b.put_u16(mini(visible_loot.size(), 65535)) for l in visible_loot: b.put_u32(l.id) b.put_float(l.pos.x) b.put_float(l.pos.y) b.put_u8(Items.index_of(l.item)) return b.data_array static func decode_snapshot(data: PackedByteArray) -> Dictionary: var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var snap := { "tick": b.get_u32(), "cleared_countdown": b.get_u8(), "players": [], "enemies": [], "boss": null, "loot": [], "inventory": [], } var pcount := b.get_u8() for _i in pcount: snap["players"].append({ "peer": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "aim": float(b.get_u16()) / 65536.0 * TAU, "hp": b.get_u16(), "max_hp": b.get_u16(), "flags": b.get_u8(), "escape": float(b.get_u8()) / 255.0, "respawn_wait": float(b.get_u8()) / 10.0, "last_input_tick": b.get_u32(), "colour": Color.hex(b.get_u32()), "total_xp": b.get_u32(), }) var ecount := b.get_u16() for _i in ecount: snap["enemies"].append({ "id": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "hp": b.get_u16(), "radius": float(b.get_u8()) * 0.5, "visual": b.get_u8(), }) if b.get_u8() == 1: snap["boss"] = { "id": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "hp": b.get_u32(), "phase": b.get_u8(), } var inventory: Array[int] = [] for _i in SimConfig.INVENTORY_SLOTS: inventory.append(b.get_u8()) snap["inventory"] = inventory var lcount := b.get_u16() for _i in lcount: snap["loot"].append({ "id": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "item": b.get_u8(), }) return snap # --- Events ----------------------------------------------------------------- ## Events the client never sees; the instance layer consumes them server-side. ## These describe a decision the server is about to act on, not an outcome -- ## leaking them would tell a client about a message it might try to forge. const SERVER_ONLY := [ SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED, ] ## [param server_tick] rides along so the client can fast-forward a bullet by ## however many ticks the packet spent in flight, instead of popping it in at ## the muzzle a round-trip late. static func encode_events(server_tick: int, events: Array[Dictionary]) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u32(server_tick) var count := 0 var body := StreamPeerBuffer.new() body.big_endian = false for ev in events: var t := int(ev["t"]) if SERVER_ONLY.has(t): continue body.put_u8(t) match t: SimEvent.Type.BULLET_SPAWN: body.put_u32(ev["uid"]) body.put_float(ev["pos"].x) body.put_float(ev["pos"].y) body.put_float(ev["vel"].x) body.put_float(ev["vel"].y) body.put_float(ev["r"]) body.put_u16(clampi(int(ev["life"]), 0, 65535)) body.put_u8(int(ev["kind"])) body.put_u8(int(ev["team"])) body.put_float(ev["accel"]) body.put_float(ev["turn"]) SimEvent.Type.BULLET_DESPAWN: body.put_u32(ev["uid"]) SimEvent.Type.PLAYER_HIT: body.put_u32(ev["peer"]) body.put_u16(clampi(int(ev["dmg"]), 0, 65535)) body.put_u16(clampi(int(ev["hp"]), 0, 65535)) SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: body.put_u32(ev["peer"]) SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ SimEvent.Type.ITEM_DROPPED: body.put_u32(ev["peer"]) # By index, like everywhere else on the wire. See Items.ORDER. body.put_u8(Items.index_of(ev["item"])) SimEvent.Type.PLAYER_RESPAWNED: body.put_u32(ev["peer"]) body.put_float(ev["pos"].x) body.put_float(ev["pos"].y) SimEvent.Type.ENEMY_HIT: body.put_u32(ev["id"]) body.put_u16(clampi(int(ev["dmg"]), 0, 65535)) body.put_u32(maxi(int(ev["hp"]), 0)) SimEvent.Type.ENEMY_DIED: body.put_u32(ev["id"]) SimEvent.Type.BOSS_PHASE: body.put_u8(clampi(int(ev["phase"]), 0, 255)) SimEvent.Type.BOSS_DIED: pass count += 1 b.put_u16(count) b.put_data(body.data_array) return b.data_array ## Returns { "tick": int, "events": Array[Dictionary] }. static func decode_events(data: PackedByteArray) -> Dictionary: var out: Array[Dictionary] = [] var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var server_tick := b.get_u32() var count := b.get_u16() for _i in count: var t := b.get_u8() var ev := {"t": t} match t: SimEvent.Type.BULLET_SPAWN: ev["uid"] = b.get_u32() ev["pos"] = Vector2(b.get_float(), b.get_float()) ev["vel"] = Vector2(b.get_float(), b.get_float()) ev["r"] = b.get_float() ev["life"] = b.get_u16() ev["kind"] = b.get_u8() ev["team"] = b.get_u8() ev["accel"] = b.get_float() ev["turn"] = b.get_float() SimEvent.Type.BULLET_DESPAWN: ev["uid"] = b.get_u32() SimEvent.Type.PLAYER_HIT: ev["peer"] = b.get_u32() ev["dmg"] = b.get_u16() ev["hp"] = b.get_u16() SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: ev["peer"] = b.get_u32() SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ SimEvent.Type.ITEM_DROPPED: ev["peer"] = b.get_u32() ev["item"] = Items.by_index(b.get_u8()) SimEvent.Type.PLAYER_RESPAWNED: ev["peer"] = b.get_u32() ev["pos"] = Vector2(b.get_float(), b.get_float()) SimEvent.Type.ENEMY_HIT: ev["id"] = b.get_u32() ev["dmg"] = b.get_u16() ev["hp"] = b.get_u32() SimEvent.Type.ENEMY_DIED: ev["id"] = b.get_u32() SimEvent.Type.BOSS_PHASE: ev["phase"] = b.get_u8() SimEvent.Type.BOSS_DIED: pass out.append(ev) return {"tick": server_tick, "events": out} # --- Input ------------------------------------------------------------------ static func encode_inputs(frames: Array[InputFrame]) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u8(mini(frames.size(), 255)) for f in frames: f.write(b) return b.data_array static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]: var out: Array[InputFrame] = [] if data.size() < 1: return out var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var count := b.get_u8() # A malformed or hostile packet must not make the server read past the end. if data.size() < 1 + count * InputFrame.SIZE: return out for _i in count: out.append(InputFrame.read(b)) return out # --- Roster ----------------------------------------------------------------- # Who is online and where. Low frequency (membership changes only), so it is # the one message that carries strings; everything hot stays fixed-width. static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u8(mini(entries.size(), 255)) for e in entries: b.put_u32(int(e["peer"])) b.put_utf8_string(String(e["name"])) b.put_u8(int(e["kind"])) b.put_u32(int(e["instance"])) b.put_u8(1 if e["alive"] else 0) return b.data_array static func decode_roster(data: PackedByteArray) -> Array[Dictionary]: var out: Array[Dictionary] = [] if data.size() < 1: return out var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var count := b.get_u8() for _i in count: if b.get_available_bytes() < 4: break var peer := b.get_u32() var display := _safe_utf8(b) # kind, instance, alive. if b.get_available_bytes() < 1 + 4 + 1: break out.append({ "peer": peer, "name": display, "kind": b.get_u8(), "instance": b.get_u32(), "alive": b.get_u8() == 1, }) return out # --- Map chunks ------------------------------------------------------------- static func encode_map_chunks(map: MapGrid, ids: Array) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u8(mini(ids.size(), 255)) for id in ids: b.put_u16(int(id)) b.put_data(map.encode_chunk(int(id))) return b.data_array ## Applies straight into [param map]. Chunk sizes come from the map's own ## dimensions, which the client learned at enter_instance, so a truncated or ## hostile packet cannot make it read past the end. static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int: if data.size() < 1: return 0 var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var count := b.get_u8() var applied := 0 for _i in count: if b.get_available_bytes() < 2: break var id := b.get_u16() var r := map.chunk_rect(id) var n := r.size.x * r.size.y if n <= 0 or b.get_available_bytes() < n: break map.apply_chunk(id, b.get_data(n)[1]) applied += 1 return applied # --- Character roster ------------------------------------------------------- # Sent once at login and after any change. Low frequency and carries strings, # like the online roster, so it is the same fixed-header-then-utf8 shape. static func encode_characters(chars: Array[Character], selected: String) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_utf8_string(selected) b.put_u8(mini(chars.size(), 255)) for c in chars: b.put_utf8_string(c.id) b.put_utf8_string(c.display_name) b.put_u8(clampi(c.level, 1, 255)) b.put_u32(maxi(c.total_xp, 0)) b.put_u8(clampi(roundi(c.xp_progress() * 255.0), 0, 255)) b.put_u16(clampi(c.max_hp(), 1, 65535)) b.put_u8(1 if c.active else 0) b.put_u32(c.colour.to_rgba32()) for i in SimConfig.INVENTORY_SLOTS: b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE)) return b.data_array ## Length-prefixed string read that refuses to run off the end. ## ## StreamPeerBuffer.get_utf8_string() reads a length and then that many bytes, ## and pushes an engine error if the buffer is short -- so a truncated or ## hostile packet turns into error spam plus a garbage value. Returns an empty ## string and leaves the cursor at the end instead, which callers detect via ## get_available_bytes(). static func _safe_utf8(b: StreamPeerBuffer) -> String: if b.get_available_bytes() < 4: b.seek(b.get_size()) return "" var length := b.get_u32() if length > b.get_available_bytes(): b.seek(b.get_size()) return "" return b.get_data(length)[1].get_string_from_utf8() if length > 0 else "" ## Returns { "selected": String, "characters": Array[Dictionary] }. static func decode_characters(data: PackedByteArray) -> Dictionary: var out: Array[Dictionary] = [] if data.size() < 1: return {"selected": "", "characters": out} var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var selected := _safe_utf8(b) if b.get_available_bytes() < 1: return {"selected": selected, "characters": out} var count := b.get_u8() # Bytes each entry needs after its two strings: level, xp, progress, # max_hp, active, colour, then one byte per inventory slot. var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS for _i in count: var id := _safe_utf8(b) var display := _safe_utf8(b) if id.is_empty() or b.get_available_bytes() < fixed: break out.append({ "id": id, "name": display, "level": b.get_u8(), "xp": b.get_u32(), "progress": float(b.get_u8()) / 255.0, "max_hp": b.get_u16(), "active": b.get_u8() == 1, "colour": Color.hex(b.get_u32()), "inventory": _read_inventory(b), }) return {"selected": selected, "characters": out} static func _read_inventory(b: StreamPeerBuffer) -> Array[int]: var out: Array[int] = [] for _i in SimConfig.INVENTORY_SLOTS: out.append(b.get_u8()) return out