Make the two hot decoders as careful as the six cold ones
Every colder message here checks get_available_bytes() before it reads, and has a test named for it. Snapshots and events -- twenty and sixty times a second, the two that actually matter -- did neither. A snapshot's counts are a u8 and two u16s read straight off the wire and then looped without a bounds check. A garbage enemy count is up to 65535 dictionaries built out of an empty buffer, and a garbage tick is fed directly to the client's clock estimate and its input-lead resync. The channel is unreliable and ENet's checksum is off, so a short snapshot is a network event, not only an attack. It now answers valid:false and the client drops it -- one dropped snapshot costs 50ms of interpolation. Events were worse in kind. The decoder read a type byte and matched on it, so a type it did not recognise consumed one byte and carried on, reading every field after it at the wrong offset: one stray byte became a stream of bullets spawned at invented positions, which the client then applied. Lengths are now declared up front and an unknown type stops the stream. The prefix is kept, because the channel is reliable and ordered and throwing the packet away would strand every despawn in it. Both size tables are pinned to their encoders by tests that encode one of each record and compare, so they cannot drift -- and a new SimEvent.Type with no declared length fails a test rather than shipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39d857c4d1
commit
20715e77cc
3 files changed
+224
-4
No files matched your search
@@ -450,6 +450,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
|
||||
func on_snapshot(data: PackedByteArray) -> void:
|
||||
var snap := NetCodec.decode_snapshot(data)
|
||||
if not snap["valid"]:
|
||||
# Unreliable channel, no checksum: a truncated snapshot is a network
|
||||
# event, not only an attack. Dropping one costs 50ms of interpolation;
|
||||
# applying half of one moves the clock estimate and the input lead on
|
||||
# numbers read out of the end of a buffer.
|
||||
return
|
||||
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
|
||||
return # stale or duplicate; unreliable channel, newest wins
|
||||
cleared_countdown = int(snap["cleared_countdown"])
|
||||
|
||||
+78
-4
@@ -140,18 +140,41 @@ static func encode_snapshot(world: SimWorld,
|
||||
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": b.get_u32(),
|
||||
"cleared_countdown": b.get_u8(),
|
||||
"tick": 0, "cleared_countdown": Protocol.COUNTDOWN_NONE,
|
||||
"players": [], "enemies": [], "boss": null,
|
||||
"loot": [], "inventory": [],
|
||||
"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(),
|
||||
@@ -167,7 +190,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"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(),
|
||||
@@ -177,7 +204,12 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"visual": b.get_u8(),
|
||||
})
|
||||
|
||||
if b.get_u8() == 1:
|
||||
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()),
|
||||
@@ -185,18 +217,23 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"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
|
||||
|
||||
|
||||
@@ -279,16 +316,53 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user