Files
transcience/src/net/net_codec.gd
T
claudeandClaude Opus 5 a6084c1a8d
ci / verify (push) Successful in 52s
Tell the roster the truth about a wounded character
Health survives a run now, so a character sitting on the select screen
can be nearly dead -- and the screen was still printing only the ceiling.
"level 11, 2000 hp" for a character on 160 is a lie told at the exact
moment it matters most, because the next thing that happens is the player
walks it into a dungeon under permadeath.

The roster message carries current hit points alongside the maximum, and
the screen shows a fraction only when it means something: a healthy
character still reads "1300 hp" rather than "1300/1300". The field is
inserted mid-record, which shifts every field after it, so the protocol
version goes to 10 -- a mismatched client would read the active flag out
of the middle of a health value.

tools/screenshot.gd had no capture for this screen, which is how a UI
change to it could have shipped unlooked-at. It now shows a made-up
roster of three characters -- whole, badly hurt, nearly dead -- for the
same reason the upgrade screen is fed a made-up offer: the point is the
layout, not a dungeon run. Getting there needs the player walked off the
quartermaster first, or the game scene re-opens the upgrade screen over
the top of it every frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:54:40 +02:00

693 lines
23 KiB
GDScript

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)
# Sliced, not just clamped. Writing mini(size, 255) as the header and then
# looping the full array puts records after the count on the wire, and the
# decoder reads the enemy count out of the middle of a player. MAX_CLIENTS
# keeps it unreachable today, which is exactly why it would survive to the
# day it is not.
visible_players = visible_players.slice(0, 255)
b.put_u8(visible_players.size())
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)
live_enemies = live_enemies.slice(0, 65535)
b.put_u16(live_enemies.size())
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)
visible_loot = visible_loot.slice(0, 65535)
b.put_u16(visible_loot.size())
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
# Fixed record sizes, mirroring what encode_snapshot writes. Pinned against the
# encoder by test_net_codec so the two cannot drift.
const SNAPSHOT_HEADER := 5 ## u32 tick + u8 countdown
const SNAPSHOT_PLAYER := 33
const SNAPSHOT_ENEMY := 16
const SNAPSHOT_BOSS := 17
const SNAPSHOT_LOOT := 13
## Decode one snapshot. The returned dictionary carries "valid": a short or
## corrupt packet answers false and the caller drops it.
##
## Snapshots ride an unreliable channel with no checksum, so a truncated one is
## a network event rather than only an attack -- and this decoder used to read
## straight through the end of the buffer. A garbage enemy count is up to 65535
## dictionaries built out of nothing, twenty times a second, and a garbage tick
## poisons the client's clock estimate and its input lead. Every colder message
## here already checks; the two hottest did not.
static func decode_snapshot(data: PackedByteArray) -> Dictionary:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var snap := {
"tick": 0, "cleared_countdown": Protocol.COUNTDOWN_NONE,
"players": [], "enemies": [], "boss": null,
"loot": [], "inventory": [], "valid": false,
}
if b.get_available_bytes() < SNAPSHOT_HEADER + 1:
return snap
snap["tick"] = b.get_u32()
snap["cleared_countdown"] = b.get_u8()
var pcount := b.get_u8()
if b.get_available_bytes() < pcount * SNAPSHOT_PLAYER:
return snap
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(),
})
if b.get_available_bytes() < 2:
return snap
var ecount := b.get_u16()
if b.get_available_bytes() < ecount * SNAPSHOT_ENEMY:
return snap
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_available_bytes() < 1:
return snap
var has_boss := b.get_u8() == 1
if has_boss and b.get_available_bytes() < SNAPSHOT_BOSS:
return snap
if has_boss:
snap["boss"] = {
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"hp": b.get_u32(),
"phase": b.get_u8(),
}
if b.get_available_bytes() < SimConfig.INVENTORY_SLOTS + 2:
return snap
var inventory: Array[int] = []
for _i in SimConfig.INVENTORY_SLOTS:
inventory.append(b.get_u8())
snap["inventory"] = inventory
var lcount := b.get_u16()
if b.get_available_bytes() < lcount * SNAPSHOT_LOOT:
return snap
for _i in lcount:
snap["loot"].append({
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"item": b.get_u8(),
})
snap["valid"] = true
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.TELEGRAPH:
body.put_float(ev["pos"].x)
body.put_float(ev["pos"].y)
body.put_float(ev["r"])
body.put_u16(clampi(int(ev["ticks"]), 0, 65535))
SimEvent.Type.BOSS_DIED:
pass
count += 1
b.put_u16(count)
b.put_data(body.data_array)
return b.data_array
## Bytes an event of [param t] writes after its type byte, or -1 for a type this
## build cannot parse.
##
## The decoder needs this BEFORE the match, because an event whose length it
## does not know is not a single bad record: every field after it is read at the
## wrong offset, so one stray byte becomes a stream of invented bullets at
## invented positions. Pinned against encode_events by test_net_codec.
static func event_payload_size(t: int) -> int:
match t:
SimEvent.Type.BULLET_SPAWN: return 36
SimEvent.Type.BULLET_DESPAWN: return 4
SimEvent.Type.PLAYER_HIT: return 8
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: return 4
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED: return 5
SimEvent.Type.PLAYER_RESPAWNED: return 12
SimEvent.Type.ENEMY_HIT: return 10
SimEvent.Type.ENEMY_DIED: return 4
SimEvent.Type.BOSS_PHASE: return 1
SimEvent.Type.TELEGRAPH: return 14
SimEvent.Type.BOSS_DIED: return 0
return -1
## Returns { "tick": int, "events": Array[Dictionary] }.
##
## Decoding stops at the first record that does not fit or that names a type
## this build has no length for. The stream is reliable and ordered, so the
## prefix decoded before that point is sound and is kept -- unlike a snapshot,
## dropping the whole packet would strand every bullet it was about to despawn.
static func decode_events(data: PackedByteArray) -> Dictionary:
var out: Array[Dictionary] = []
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
if b.get_available_bytes() < 6:
return {"tick": 0, "events": out}
var server_tick := b.get_u32()
var count := b.get_u16()
for _i in count:
if b.get_available_bytes() < 1:
break
var t := b.get_u8()
var payload := event_payload_size(t)
if payload < 0 or b.get_available_bytes() < payload:
break
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.TELEGRAPH:
ev["pos"] = Vector2(b.get_float(), b.get_float())
ev["r"] = b.get_float()
ev["ticks"] = b.get_u16()
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
entries = entries.slice(0, 255)
b.put_u8(entries.size())
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
# --- Portals ----------------------------------------------------------------
# Sent once, with enter_instance. There are two of them and they never move, so
# this is about as cold as a message gets -- it is a codec only because the
# count is variable and RPC arguments are not.
static func encode_portals(portals: Array[SimPortal]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(portals.size(), 255))
for portal in portals:
b.put_float(portal.pos.x)
b.put_float(portal.pos.y)
# By index, like item ids. See Dungeons.ORDER.
b.put_u8(Dungeons.index_of(portal.dungeon))
return b.data_array
## Returns [{ "pos": Vector2, "dungeon": StringName }].
static func decode_portals(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:
# 4 + 4 + 1. A truncated packet gives back fewer portals rather than
# reading past the end and inventing one at a garbage position.
if b.get_available_bytes() < 9:
break
out.append({
"pos": Vector2(b.get_float(), b.get_float()),
"dungeon": Dungeons.by_index(b.get_u8()),
})
return out
# --- Upgrade state ----------------------------------------------------------
# Sent to one peer when its played character's upgrades change: a level gained,
# a choice spent, a character swapped in. Cold, and about the played character
# only -- nobody needs to know what anyone else has taken.
static func encode_upgrade_state(pending: int, offer: Array[StringName],
taken: Array[StringName]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(clampi(pending, 0, 255))
b.put_u8(mini(offer.size(), 255))
for id in offer:
b.put_u8(Upgrades.index_of(id))
b.put_u16(mini(taken.size(), 65535))
for id in taken:
b.put_u8(Upgrades.index_of(id))
return b.data_array
## Returns { "pending": int, "offer": Array[StringName], "taken": Array[StringName] }.
static func decode_upgrade_state(data: PackedByteArray) -> Dictionary:
var offer: Array[StringName] = []
var taken: Array[StringName] = []
var out := {"pending": 0, "offer": offer, "taken": taken}
if data.size() < 2:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
out["pending"] = b.get_u8()
var offer_count := b.get_u8()
for _i in offer_count:
if b.get_available_bytes() < 1:
return out
var id := Upgrades.by_index(b.get_u8())
if not id.is_empty():
offer.append(id)
if b.get_available_bytes() < 2:
return out
var taken_count := b.get_u16()
for _i in taken_count:
if b.get_available_bytes() < 1:
return out
var id := Upgrades.by_index(b.get_u8())
if not id.is_empty():
taken.append(id)
return out
# --- 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))
var ceiling := c.max_hp()
b.put_u16(clampi(ceiling, 1, 65535))
# Current health as well as the ceiling. Health survives a run now, so a
# character on the roster can be badly hurt, and a screen that showed
# only the maximum would advertise a wounded character as whole -- which
# under permadeath is the worst possible thing to be wrong about.
b.put_u16(clampi(ceiling if c.hp == Character.HP_FULL else c.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, hp, active, colour, then one byte per inventory slot.
var fixed := 1 + 4 + 1 + 2 + 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(),
"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