Files
transcience/src/net/net_codec.gd
T
Adyrem 1dc1952a3c
ci / verify (push) Successful in 45s
Fix bullet/ship desync; rework death, escape, arrival and hub awareness
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
2026-09-03 18:43:19 +02:00

291 lines
8.7 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.
static func encode_snapshot(world: SimWorld,
cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u32(world.tick)
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
b.put_u8(mini(world.players.size(), 255))
for p in world.players.values():
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))
var flags := 0
if p.alive:
flags |= Protocol.F_ALIVE
if p.iframes > 0:
flags |= Protocol.F_INVULN
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))
# Echoed so the owning client knows how far to rewind when reconciling.
b.put_u32(p.last_input_tick)
var live_enemies: Array[SimEnemy] = []
for e in world.enemies.values():
if e.alive:
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
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))
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,
}
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(),
"flags": b.get_u8(),
"escape": float(b.get_u8()) / 255.0,
"last_input_tick": 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(),
}
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:
body.put_u32(ev["peer"])
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:
ev["peer"] = b.get_u32()
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:
# get_utf8_string reads its own length prefix, so a truncated packet
# yields empty strings rather than reading off the end.
out.append({
"peer": b.get_u32(),
"name": b.get_utf8_string(),
"kind": b.get_u8(),
"instance": b.get_u32(),
"alive": b.get_u8() == 1,
})
return out