Reject impossible movement before relay and correct sender

This commit is contained in:
Nomads_Reach
2026-08-15 21:48:19 -04:00
parent b4fe87e1ca
commit 8571056fb3
+94 -5
View File
@@ -41,6 +41,18 @@ MAX_PACKETS_PER_SECOND = 120
MAX_CONNECT_ATTEMPTS = 8
CONNECT_ATTEMPT_WINDOW_SECONDS = 10.0
EXTERIOR_INTEREST_RADIUS = 8192.0
MAX_NORMAL_MOVEMENT_SPEED = 2500.0
MOVEMENT_GRACE_DISTANCE = 512.0
MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS = 5.0
MOVEMENT_TRANSITION_TYPES = frozenset({
"teleport",
"cell_change",
"worldspace_change",
"load",
"spawn",
"fast_travel",
})
ALLOWED_MOVEMENT_TYPES = MOVEMENT_TRANSITION_TYPES | {"normal"}
_LOG_LEVELS = {
"debug": 10,
@@ -228,6 +240,8 @@ class FalloutTogetherServer:
"transformPacketsReceived": 0,
"transformPacketsBroadcast": 0,
"transformPacketsInterestFiltered": 0,
"movementPacketsRejected": 0,
"movementCorrectionsSent": 0,
"worldStatePacketsReceived": 0,
"worldStatePacketsBroadcast": 0,
"npcStatePacketsReceived": 0,
@@ -589,7 +603,7 @@ class FalloutTogetherServer:
"serverName": self.server_name,
"serverDescription": self.server_description,
"protocolVersion": PROTOCOL_VERSION,
"capabilities": ["interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1"],
"capabilities": ["interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1", "movement-correction-v1"],
},
)
buffer = b""
@@ -705,9 +719,21 @@ class FalloutTogetherServer:
if normalized is None:
self._reject_packet(client, "Malformed transform")
return False
accepted_monotonic = time.monotonic()
movement_valid, movement_reason = self._validate_transform_movement(
client,
normalized,
accepted_monotonic,
)
if not movement_valid:
with self._lock:
self._stats["movementPacketsRejected"] += 1
self._reject_packet(client, movement_reason)
self._send_position_correction(client, movement_reason)
return False
normalized["playerId"] = client.player_id
normalized["serverTime"] = time.time()
client.record_transform(normalized)
client.record_transform(normalized, accepted_monotonic)
with self._lock:
self._stats["transformPacketsReceived"] += 1
self._broadcast_transform(client, normalized)
@@ -789,6 +815,10 @@ class FalloutTogetherServer:
if not _is_hex_form_id(worldspace_id, allow_empty=True, allow_zero=True):
return None
movement_type = packet.get("movementType", "normal")
if not isinstance(movement_type, str) or movement_type not in ALLOWED_MOVEMENT_TYPES:
return None
normalized = dict(packet)
normalized["x"] = float(packet["x"])
normalized["y"] = float(packet["y"])
@@ -796,9 +826,7 @@ class FalloutTogetherServer:
normalized["angleZ"] = float(packet["angleZ"])
normalized["cellId"] = _normalize_hex_form_id(packet["cellId"])
normalized["worldspaceId"] = _normalize_hex_form_id(worldspace_id) if worldspace_id else ""
movement_type = packet.get("movementType", "normal")
normalized["movementType"] = str(movement_type)[:32] if isinstance(movement_type, str) else "normal"
normalized["movementType"] = movement_type
for field_name in ("movementSpeed", "animationGraphSpeed"):
if field_name in packet:
value = packet[field_name]
@@ -826,6 +854,67 @@ class FalloutTogetherServer:
normalized["actionEvents"] = _normalize_action_events(packet["actionEvents"])
return normalized
def _validate_transform_movement(
self,
client: ClientSession,
packet: dict[str, Any],
now_monotonic: float,
) -> tuple[bool, str]:
previous, previous_monotonic = client.get_last_transform_anchor()
if previous is None or previous_monotonic is None:
return True, "first transform"
movement_type = packet.get("movementType", "normal")
if movement_type in MOVEMENT_TRANSITION_TYPES:
return True, f"explicit {movement_type} transition"
previous_cell = previous.get("cellId", "")
current_cell = packet.get("cellId", "")
previous_world = previous.get("worldspaceId", "")
current_world = packet.get("worldspaceId", "")
if previous_cell != current_cell or previous_world != current_world:
return False, "scope changed without an explicit movement transition"
elapsed = now_monotonic - previous_monotonic
if not math.isfinite(elapsed) or elapsed < 0.0:
elapsed = 0.0
elapsed = min(elapsed, MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS)
dx = packet["x"] - previous["x"]
dy = packet["y"] - previous["y"]
dz = packet["z"] - previous["z"]
distance = math.sqrt((dx * dx) + (dy * dy) + (dz * dz))
allowed_distance = MOVEMENT_GRACE_DISTANCE + (MAX_NORMAL_MOVEMENT_SPEED * elapsed)
if not math.isfinite(distance) or distance > allowed_distance:
return (
False,
f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s",
)
return True, "normal movement accepted"
def _send_position_correction(self, client: ClientSession, reason: str) -> None:
previous, _previous_monotonic = client.get_last_transform_anchor()
if previous is None:
return
packet: dict[str, Any] = {
"type": "positionCorrection",
"reason": reason[:160],
"x": previous["x"],
"y": previous["y"],
"z": previous["z"],
"angleZ": previous["angleZ"],
"cellId": previous["cellId"],
"worldspaceId": previous.get("worldspaceId", ""),
"serverTime": time.time(),
}
try:
self._send_packet(client, packet)
except (OSError, ValueError):
self._disconnect_client(client)
return
with self._lock:
self._stats["movementCorrectionsSent"] += 1
def _normalize_world_state_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None:
normalized = dict(packet)
if "gameHour" in packet: