From ca962e45117d6a0ecf38c65fcb85477867028153 Mon Sep 17 00:00:00 2001 From: Nomads_Reach <144523850+NomadsReach@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:06:06 -0400 Subject: [PATCH] Define wrap-safe snapshot sequencing --- server/snapshot_sequence.py | 60 ++++++++++++++++++++++++++ server/tests/test_snapshot_sequence.py | 43 ++++++++++++++++++ server/tests/test_transport_policy.py | 11 ++++- server/transport_policy.py | 10 +++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 server/snapshot_sequence.py create mode 100644 server/tests/test_snapshot_sequence.py diff --git a/server/snapshot_sequence.py b/server/snapshot_sequence.py new file mode 100644 index 0000000..11f909f --- /dev/null +++ b/server/snapshot_sequence.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +MAX_SEQUENCE = 0xFFFFFFFF +_HALF_RANGE = 0x80000000 + + +def next_sequence(current: int) -> int: + if not isinstance(current, int) or isinstance(current, bool) or not 0 <= current <= MAX_SEQUENCE: + raise ValueError("snapshot sequence must be an unsigned 32-bit integer") + value = (current + 1) & MAX_SEQUENCE + return 1 if value == 0 else value + + +def is_newer(candidate: int, baseline: int) -> bool: + if not isinstance(candidate, int) or isinstance(candidate, bool): + return False + if not isinstance(baseline, int) or isinstance(baseline, bool): + return False + if not 0 <= candidate <= MAX_SEQUENCE or not 0 <= baseline <= MAX_SEQUENCE: + return False + if candidate == 0: + return False + if baseline == 0: + return True + delta = (candidate - baseline) & MAX_SEQUENCE + return 0 < delta < _HALF_RANGE + + +class SequenceCounter: + def __init__(self) -> None: + self._value = 0 + + @property + def current(self) -> int: + return self._value + + def advance(self) -> int: + self._value = next_sequence(self._value) + return self._value + + def reset(self) -> None: + self._value = 0 + + +class SequenceWindow: + def __init__(self) -> None: + self._last_accepted = 0 + + @property + def last_accepted(self) -> int: + return self._last_accepted + + def accept(self, sequence: int) -> bool: + if not is_newer(sequence, self._last_accepted): + return False + self._last_accepted = sequence + return True + + def reset(self) -> None: + self._last_accepted = 0 diff --git a/server/tests/test_snapshot_sequence.py b/server/tests/test_snapshot_sequence.py new file mode 100644 index 0000000..0a1b94e --- /dev/null +++ b/server/tests/test_snapshot_sequence.py @@ -0,0 +1,43 @@ +from snapshot_sequence import MAX_SEQUENCE, SequenceCounter, SequenceWindow, is_newer, next_sequence + + +def test_next_sequence_skips_zero_after_wrap(): + assert next_sequence(0) == 1 + assert next_sequence(1) == 2 + assert next_sequence(MAX_SEQUENCE) == 1 + + +def test_is_newer_is_wrap_safe(): + assert is_newer(1, 0) + assert is_newer(2, 1) + assert not is_newer(1, 1) + assert not is_newer(1, 2) + assert is_newer(1, MAX_SEQUENCE) + assert not is_newer(MAX_SEQUENCE, 1) + assert not is_newer(0, MAX_SEQUENCE) + + +def test_counter_and_window_enforce_latest_wins(): + counter = SequenceCounter() + assert counter.current == 0 + assert counter.advance() == 1 + assert counter.advance() == 2 + counter.reset() + assert counter.advance() == 1 + + window = SequenceWindow() + assert window.accept(1) + assert not window.accept(1) + assert window.accept(2) + assert not window.accept(1) + window.reset() + assert window.accept(MAX_SEQUENCE) + assert window.accept(1) + assert not window.accept(MAX_SEQUENCE) + + +def test_invalid_sequence_inputs_are_rejected(): + assert not is_newer(-1, 0) + assert not is_newer(1, -1) + assert not is_newer(True, 0) + assert not is_newer(1, MAX_SEQUENCE + 1) diff --git a/server/tests/test_transport_policy.py b/server/tests/test_transport_policy.py index 901fa51..3f86654 100644 --- a/server/tests/test_transport_policy.py +++ b/server/tests/test_transport_policy.py @@ -1,4 +1,9 @@ -from transport_policy import Delivery, delivery_for_packet_type, is_snapshot_packet +from transport_policy import ( + Delivery, + delivery_for_packet_type, + is_snapshot_packet, + requires_application_sequence, +) def test_snapshot_packets_use_unreliable_sequenced_delivery(): @@ -6,6 +11,8 @@ def test_snapshot_packets_use_unreliable_sequenced_delivery(): assert delivery_for_packet_type("npcState") is Delivery.UNRELIABLE_SEQUENCED assert is_snapshot_packet("transform") assert is_snapshot_packet("npcState") + assert requires_application_sequence("transform") + assert requires_application_sequence("npcState") def test_gameplay_and_control_packets_use_reliable_ordered_delivery(): @@ -25,7 +32,9 @@ def test_gameplay_and_control_packets_use_reliable_ordered_delivery(): for packet_type in reliable_types: assert delivery_for_packet_type(packet_type) is Delivery.RELIABLE_ORDERED assert not is_snapshot_packet(packet_type) + assert not requires_application_sequence(packet_type) def test_unknown_packet_types_never_default_to_unreliable(): assert delivery_for_packet_type("futureControlPacket") is Delivery.RELIABLE_ORDERED + assert not requires_application_sequence("futureControlPacket") diff --git a/server/transport_policy.py b/server/transport_policy.py index d50c5fb..3385762 100644 --- a/server/transport_policy.py +++ b/server/transport_policy.py @@ -26,3 +26,13 @@ def delivery_for_packet_type(packet_type: str) -> Delivery: def is_snapshot_packet(packet_type: str) -> bool: return delivery_for_packet_type(packet_type) is Delivery.UNRELIABLE_SEQUENCED + + +def requires_application_sequence(packet_type: str) -> bool: + """Return whether the protocol must reject stale copies after reordering. + + GameNetworkingSockets can deliver unreliable messages without retransmit, + but the application still owns latest-wins snapshot semantics. Sequence + numbers are therefore required for every unreliable snapshot family. + """ + return is_snapshot_packet(packet_type)