diff --git a/.agents/lessons-learned.md b/.agents/lessons-learned.md
index 529088e..ebbdf03 100644
--- a/.agents/lessons-learned.md
+++ b/.agents/lessons-learned.md
@@ -25,3 +25,4 @@ fact.
2026-07-21: A Harmony POSTFIX on the game load method cannot stall the loading screen: it runs only after the load method returns, so a stuck load means the hook never fired (a fired-but-slow postfix would show as a hang after the screen clears, not a frozen loading screen). Still, wrap the event invoke in try/catch so a subscriber exception can never propagate back into the game's load and abort it.
2026-07-22: Bash fixture runners must remove CR from .env input before sourcing because Windows checkout conversion otherwise leaves carriage returns in exported values under POSIX shells.
2026-07-22: SaveLoadManager.readyToSave is the common static world-ready marker set by both StartMenu new-game and continue coroutines; LoadGame runs only for continue, so API readiness must poll readyToSave rather than depend on the LoadGame postfix.
+2026-07-24: LiteNetLib 1.3.1 connected packets use a two-bit ConnectionNumber: stale mismatches are rejected before liveness or state handling, while user data, Ping, Pong, and Disconnect carry the active number.
diff --git a/packages/net/Sailwind.Online.Net/ITransport.cs b/packages/net/Sailwind.Online.Net/ITransport.cs
index 7dae4c5..c2e1778 100644
--- a/packages/net/Sailwind.Online.Net/ITransport.cs
+++ b/packages/net/Sailwind.Online.Net/ITransport.cs
@@ -20,14 +20,26 @@ public interface ITransport
/// True while the current peer is fully connected, i.e. safe to send on.
bool IsPeerConnected { get; }
+ ///
+ /// Maximum payload bytes the current peer can send as one unreliable packet, or zero when
+ /// there is no current peer.
+ ///
+ int MaxUnreliablePayloadSize { get; }
+
/// Round-trip estimate in milliseconds for the current peer, or -1 when there is none.
int Ping { get; }
/// Bring the manager up. Returns false when the socket cannot bind.
bool Start();
- /// Open (or re-open) the single peer to : with the connect key.
- void Connect(string host, int port, string key);
+ ///
+ /// Open (or keep opening) the single peer to :
+ /// with the connect key. Returns true when a current peer exists after the attempt.
+ ///
+ bool Connect(string host, int port, string key);
+
+ /// Immediately drop the current peer without reporting a network-originated disconnect.
+ void DropPeer();
/// Send one datagram to the current peer with the given delivery method.
void Send(byte[] data, DeliveryMethod deliveryMethod);
diff --git a/packages/net/Sailwind.Online.Net/LiteNetLibTransport.cs b/packages/net/Sailwind.Online.Net/LiteNetLibTransport.cs
index 938615d..c41c5f4 100644
--- a/packages/net/Sailwind.Online.Net/LiteNetLibTransport.cs
+++ b/packages/net/Sailwind.Online.Net/LiteNetLibTransport.cs
@@ -16,7 +16,7 @@ public sealed class LiteNetLibTransport : ITransport
{
private readonly EventBasedNetListener _listener = new EventBasedNetListener();
private readonly NetManager _manager;
- private NetPeer? _peer;
+ private readonly CurrentPeerSlot _peer = new CurrentPeerSlot();
public LiteNetLibTransport()
{
@@ -40,12 +40,31 @@ public bool IsRunning
public bool IsPeerConnected
{
- get { return _peer != null && _peer.ConnectionState == ConnectionState.Connected; }
+ get
+ {
+ NetPeer? peer = _peer.Value;
+ return peer != null && peer.ConnectionState == ConnectionState.Connected;
+ }
+ }
+
+ public int MaxUnreliablePayloadSize
+ {
+ get
+ {
+ NetPeer? peer = _peer.Value;
+ return peer != null
+ ? peer.GetMaxSinglePacketSize(DeliveryMethod.Unreliable)
+ : 0;
+ }
}
public int Ping
{
- get { return _peer != null ? _peer.Ping : -1; }
+ get
+ {
+ NetPeer? peer = _peer.Value;
+ return peer != null ? peer.Ping : -1;
+ }
}
public event Action? PeerConnected;
@@ -58,14 +77,25 @@ public bool Start()
return _manager.Start();
}
- public void Connect(string host, int port, string key)
+ public bool Connect(string host, int port, string key)
+ {
+ return _peer.SetIfPresent(_manager.Connect(host, port, key));
+ }
+
+ public void DropPeer()
{
- _peer = _manager.Connect(host, port, key);
+ NetPeer? peer = _peer.Clear();
+ if (peer == null)
+ {
+ return;
+ }
+
+ _manager.DisconnectPeerForce(peer);
}
public void Send(byte[] data, DeliveryMethod deliveryMethod)
{
- _peer?.Send(data, deliveryMethod);
+ _peer.Value?.Send(data, deliveryMethod);
}
public void PollEvents()
@@ -80,23 +110,37 @@ public void Stop()
_manager.Stop();
}
- _peer = null;
+ _peer.Clear();
}
private void OnPeerConnected(NetPeer peer)
{
- _peer = peer;
+ if (!_peer.IsCurrent(peer))
+ {
+ return;
+ }
+
PeerConnected?.Invoke();
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo info)
{
- _peer = null;
+ if (!_peer.TryClear(peer))
+ {
+ return;
+ }
+
PeerDisconnected?.Invoke(info.Reason.ToString());
}
private void OnNetworkReceive(NetPeer peer, NetPacketReader reader, byte channelNumber, DeliveryMethod deliveryMethod)
{
+ if (!_peer.IsCurrent(peer))
+ {
+ reader.Recycle();
+ return;
+ }
+
byte[] data = reader.GetRemainingBytes();
reader.Recycle();
NetworkReceive?.Invoke(data);
@@ -107,4 +151,48 @@ private void OnNetworkError(IPEndPoint endPoint, SocketError socketError)
NetworkError?.Invoke(endPoint, socketError);
}
}
+
+ internal sealed class CurrentPeerSlot
+ where TPeer : class
+ {
+ private TPeer? _value;
+
+ public TPeer? Value
+ {
+ get { return _value; }
+ }
+
+ public bool SetIfPresent(TPeer peer)
+ {
+ if (peer != null)
+ {
+ _value = peer;
+ }
+
+ return _value != null;
+ }
+
+ public TPeer? Clear()
+ {
+ TPeer? peer = _value;
+ _value = null;
+ return peer;
+ }
+
+ public bool IsCurrent(TPeer peer)
+ {
+ return ReferenceEquals(_value, peer);
+ }
+
+ public bool TryClear(TPeer peer)
+ {
+ if (!IsCurrent(peer))
+ {
+ return false;
+ }
+
+ _value = null;
+ return true;
+ }
+ }
}
diff --git a/packages/net/Sailwind.Online.Net/NetClient.cs b/packages/net/Sailwind.Online.Net/NetClient.cs
index 386b6af..49ad92a 100644
--- a/packages/net/Sailwind.Online.Net/NetClient.cs
+++ b/packages/net/Sailwind.Online.Net/NetClient.cs
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
+using System.Globalization;
using System.Net;
using System.Net.Sockets;
using LiteNetLib;
@@ -66,6 +67,8 @@ public sealed class NetClient : IServerMessageHandler, IClientStateSender, IDisp
private ulong _playerId;
private uint _serverDay;
private float _serverTimeOfDay;
+ private bool _loggedFirstOutboundPosition;
+ private bool _loggedFirstInboundPosition;
/// Production constructor: drives the real LiteNetLib 1.3.1 transport.
public NetClient(INetLog log)
@@ -159,15 +162,27 @@ public string StatusText
/// Begin (or restart) a session with the given options.
public void Connect(ConnectOptions options)
{
- _options = options;
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
- if (!_transport.IsRunning && !_transport.Start())
+ if (_status == ConnectionStatus.Connecting && ReferenceEquals(_options, options))
{
- _log.LogError("[Sailwind.Online] Failed to start LiteNetLib NetManager.");
return;
}
- OpenPeer();
+ if (_status != ConnectionStatus.Disconnected)
+ {
+ _status = ConnectionStatus.Disconnected;
+ _transport.DropPeer();
+ }
+
+ _options = options;
+ _reconnectBackoffMs = DefaultReconnectMs;
+ ResetSession();
+
+ StartTransportAndOpenPeer();
}
/// Poll the transport and service the handshake/reconnect timers. Call every frame.
@@ -186,7 +201,7 @@ public void Poll()
}
else if (_status == ConnectionStatus.Disconnected && _options != null && now >= _nextReconnectMs)
{
- OpenPeer();
+ StartTransportAndOpenPeer();
}
_cache.PruneStale(now, SnapshotCache.DefaultStaleMs);
@@ -200,15 +215,27 @@ public void SendClientState(BoatPose pose)
return;
}
+ uint timestampMs = unchecked((uint)NowMs);
byte[] bytes = _codec.EncodeClientState(
NextSeq(),
pose.Position.X, pose.Position.Y, pose.Position.Z,
pose.Rotation.X, pose.Rotation.Y, pose.Rotation.Z, pose.Rotation.W,
pose.Velocity.X, pose.Velocity.Y, pose.Velocity.Z,
0UL,
- unchecked((uint)NowMs));
+ timestampMs);
- SendRaw(bytes);
+ if (SendRaw(bytes) && !_loggedFirstOutboundPosition)
+ {
+ _loggedFirstOutboundPosition = true;
+ _log.LogInfo(string.Format(
+ CultureInfo.InvariantCulture,
+ "[Sailwind.Online] First outbound position: player_id={0}, pos=({1}, {2}, {3}), t_ms={4}.",
+ _playerId,
+ pose.Position.X,
+ pose.Position.Y,
+ pose.Position.Z,
+ timestampMs));
+ }
}
public void Dispose()
@@ -225,12 +252,42 @@ private void OpenPeer()
return;
}
+ if (!_transport.IsRunning)
+ {
+ _status = ConnectionStatus.Disconnected;
+ ScheduleReconnect();
+ return;
+ }
+
_cache.Clear();
- _transport.Connect(options.Host, options.Port, ConnectKey);
+ ResetPositionObservability();
+ if (!_transport.Connect(options.Host, options.Port, ConnectKey))
+ {
+ _status = ConnectionStatus.Disconnected;
+ ScheduleReconnect();
+ _log.LogWarning(
+ "[Sailwind.Online] Transport did not create a peer for " +
+ options.Host + ":" + options.Port + "; will retry.");
+ return;
+ }
+
_status = ConnectionStatus.Connecting;
_log.LogInfo("[Sailwind.Online] Connecting to " + options.Host + ":" + options.Port + " ...");
}
+ private void StartTransportAndOpenPeer()
+ {
+ if (!_transport.IsRunning && !_transport.Start())
+ {
+ _status = ConnectionStatus.Disconnected;
+ ScheduleReconnect();
+ _log.LogError("[Sailwind.Online] Failed to start LiteNetLib NetManager; will retry.");
+ return;
+ }
+
+ OpenPeer();
+ }
+
private void ScheduleReconnect()
{
_nextReconnectMs = NowMs + _reconnectBackoffMs;
@@ -243,12 +300,12 @@ private uint NextSeq()
return _seq;
}
- private void SendHello()
+ private bool SendHello()
{
ConnectOptions? options = _options;
if (options == null)
{
- return;
+ return false;
}
byte[] bytes = _codec.EncodeClientHello(
@@ -260,38 +317,71 @@ private void SendHello()
options.ModVersion,
options.ApiSurfaceHash);
- SendRaw(bytes);
+ if (!SendRaw(bytes))
+ {
+ EndHandshakeAttempt();
+ return false;
+ }
+
_lastHelloMs = NowMs;
+ return true;
}
- private void SendRaw(byte[] bytes)
+ private bool SendRaw(byte[] bytes)
{
if (!_transport.IsPeerConnected)
{
- return;
+ return false;
}
- if (bytes.Length > Mtu)
+ int maxPayloadSize = _transport.MaxUnreliablePayloadSize;
+ if (maxPayloadSize <= 0 || bytes.Length > maxPayloadSize)
{
- _log.LogWarning("[Sailwind.Online] Dropping oversized packet (" + bytes.Length + " > " + Mtu + " bytes).");
- return;
+ LogOversizedPacket(bytes.Length, maxPayloadSize);
+ return false;
}
- _transport.Send(bytes, DeliveryMethod.Unreliable);
+ try
+ {
+ _transport.Send(bytes, DeliveryMethod.Unreliable);
+ }
+ catch (TooBigPacketException)
+ {
+ LogOversizedPacket(bytes.Length, maxPayloadSize);
+ return false;
+ }
+
+ return true;
+ }
+
+ private void LogOversizedPacket(int packetSize, int maxPayloadSize)
+ {
+ _log.LogWarning(
+ "[Sailwind.Online] Dropping oversized packet (" + packetSize +
+ " bytes; current unreliable capacity " + maxPayloadSize + " payload bytes).");
}
private void OnPeerConnected()
{
+ if (_status != ConnectionStatus.Connecting)
+ {
+ _log.LogDebug(
+ "[Sailwind.Online] Ignored transport connect while " + StatusText + ".");
+ return;
+ }
+
_status = ConnectionStatus.Handshaking;
- _reconnectBackoffMs = DefaultReconnectMs;
- SendHello();
- _log.LogInfo("[Sailwind.Online] Transport up; sending ClientHello.");
+ if (SendHello())
+ {
+ _log.LogInfo("[Sailwind.Online] Transport up; sending ClientHello.");
+ }
}
private void OnPeerDisconnected(string reason)
{
_status = ConnectionStatus.Disconnected;
_cache.Clear();
+ ResetPositionObservability();
ScheduleReconnect();
_log.LogInfo("[Sailwind.Online] Disconnected (" + reason + "); will retry.");
}
@@ -311,11 +401,31 @@ private void OnNetworkError(IPEndPoint endPoint, SocketError socketError)
void IServerMessageHandler.OnServerHello(ServerHello hello, uint seq)
{
+ if (_status != ConnectionStatus.Handshaking)
+ {
+ _log.LogDebug(
+ "[Sailwind.Online] Ignored ServerHello (seq " + seq + ") while " + StatusText + ".");
+ return;
+ }
+
if (!hello.Accepted)
{
- _status = ConnectionStatus.Disconnected;
- ScheduleReconnect();
- _log.LogWarning("[Sailwind.Online] ServerHello rejected: " + (hello.Reason ?? "no reason"));
+ RejectHandshake("[Sailwind.Online] ServerHello rejected: " + (hello.Reason ?? "no reason"));
+ return;
+ }
+
+ CapabilityManifest? caps = hello.Capabilities;
+ if (!caps.HasValue)
+ {
+ RejectHandshake("[Sailwind.Online] ServerHello missing capabilities; will retry.");
+ return;
+ }
+
+ if (caps.Value.ProtocolVersion != ProtocolVersion)
+ {
+ RejectHandshake(
+ "[Sailwind.Online] ServerHello protocol mismatch: client " + ProtocolVersion +
+ ", server " + caps.Value.ProtocolVersion + "; will retry.");
return;
}
@@ -323,8 +433,7 @@ void IServerMessageHandler.OnServerHello(ServerHello hello, uint seq)
_playerId = hello.PlayerId;
_reconnectBackoffMs = DefaultReconnectMs;
- CapabilityManifest? caps = hello.Capabilities;
- if (caps.HasValue && caps.Value.SnapshotHz > 0)
+ if (caps.Value.SnapshotHz > 0)
{
_snapshotHz = caps.Value.SnapshotHz;
}
@@ -429,6 +538,19 @@ private void IngestPlayer(PlayerState ps, long now)
Link = ps.AboardBoat
};
_cache.UpsertPlayer(ps.PlayerId, sample);
+
+ if (!_loggedFirstInboundPosition && HandshakeComplete && ps.PlayerId != _playerId)
+ {
+ _loggedFirstInboundPosition = true;
+ _log.LogInfo(string.Format(
+ CultureInfo.InvariantCulture,
+ "[Sailwind.Online] First inbound position: remote_player_id={0}, pos=({1}, {2}, {3}), t_ms={4}.",
+ ps.PlayerId,
+ sample.Pos.X,
+ sample.Pos.Y,
+ sample.Pos.Z,
+ sample.TMs));
+ }
}
private void IngestBoat(BoatState bs, long now)
@@ -467,6 +589,39 @@ private static NetQuat ToQuat(QuatC? q)
return new NetQuat(value.X, value.Y, value.Z, value.W);
}
+ private void ResetPositionObservability()
+ {
+ _loggedFirstOutboundPosition = false;
+ _loggedFirstInboundPosition = false;
+ }
+
+ private void ResetSession()
+ {
+ _cache.Clear();
+ _seq = 0;
+ _lastHelloMs = 0;
+ _nextReconnectMs = 0;
+ _snapshotHz = 4;
+ _playerId = 0;
+ _serverDay = 0;
+ _serverTimeOfDay = 0;
+ ResetPositionObservability();
+ }
+
+ private void RejectHandshake(string warning)
+ {
+ EndHandshakeAttempt();
+ _log.LogWarning(warning);
+ }
+
+ private void EndHandshakeAttempt()
+ {
+ _status = ConnectionStatus.Disconnected;
+ ResetPositionObservability();
+ ScheduleReconnect();
+ _transport.DropPeer();
+ }
+
public static string FormatTimeOfDay(float fractionOfDay)
{
float clamped = fractionOfDay - (float)Math.Floor(fractionOfDay);
diff --git a/packages/net/Sailwind.Online.Net/Sailwind.Online.Net.csproj b/packages/net/Sailwind.Online.Net/Sailwind.Online.Net.csproj
index 025b2a5..d7371f0 100644
--- a/packages/net/Sailwind.Online.Net/Sailwind.Online.Net.csproj
+++ b/packages/net/Sailwind.Online.Net/Sailwind.Online.Net.csproj
@@ -19,6 +19,10 @@
+
+
+
+
diff --git a/server/config.example.toml b/server/config.example.toml
index 9885116..149e7d8 100644
--- a/server/config.example.toml
+++ b/server/config.example.toml
@@ -36,6 +36,34 @@ cell_size_m = 1024.0
# throttled. Must be in 0..=3600000 (0 disables the throttle).
trade_min_interval_ms = 250
+# ClientHello throttle in milliseconds. Per-peer throttling runs before
+# protocol/hash validation, database access, and ServerHello generation. Source
+# IP session admission uses max(hello_min_interval_ms,
+# 2 * new_session_min_interval_ms + 1), which reserves one complete global
+# admission window for other source IPs after a source wins a slot. Keep the
+# hello interval at or below the client's 250 ms retry cadence so a lost
+# ServerHello can be retried on the next attempt. Must be in 1..=250; zero would
+# disable pre-authentication protection.
+hello_min_interval_ms = 250
+
+# Process-wide minimum interval between database admissions for identities
+# without an active session. Active-identity reconnects have a separate
+# per-player window, so reconnect churn cannot monopolize this new-identity
+# budget. Must be in 1..=1000.
+new_session_min_interval_ms = 30
+
+# Hard ceilings on live LiteNetLib transport peers, including peers that have
+# not authenticated yet. The per-IP ceiling prevents source-port rotation from
+# consuming the global peer budget. Both must be positive, the global value may
+# not exceed 65535, and the per-IP value may not exceed the global value.
+max_transport_peers = 1024
+max_transport_peers_per_ip = 16
+
+# Hard ceiling on persistent player rows. Existing identities can reconnect
+# after the table reaches this limit; new identities are refused without
+# creating session or world state. Must be in 1..=1000000.
+max_player_rows = 10000
+
# Per-player throttles (milliseconds) for the other inbound message classes,
# mirroring the rotation-proof, memory-bounded trade limiter above. A message
# beyond the configured rate is dropped before it drives work; an idempotent
diff --git a/server/crates/sw-net/src/lib.rs b/server/crates/sw-net/src/lib.rs
index 6125f5c..e09f241 100644
--- a/server/crates/sw-net/src/lib.rs
+++ b/server/crates/sw-net/src/lib.rs
@@ -15,18 +15,46 @@
pub mod protocol;
-use std::collections::HashMap;
+use std::collections::{HashMap, VecDeque};
use std::io;
-use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
+use std::net::{IpAddr, SocketAddr, ToSocketAddrs, UdpSocket};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// Opaque, stable identifier for a connected peer, assigned by the host.
pub type PeerId = u32;
-/// Largest datagram the host will read. Above the fixed MTU so oversized or
-/// hostile packets are received (and then rejected) rather than silently
-/// truncated at the socket layer.
-const RECV_BUFFER: usize = 2048;
+/// Largest possible UDP payload. Receiving the complete datagram lets the host
+/// reject every packet above the fixed LiteNetLib MTU without truncation.
+const RECV_BUFFER: usize = 65_535;
+
+/// Hard ceiling on socket datagrams consumed by one fixed-tick poll.
+const MAX_POLL_PACKETS: usize = 128;
+
+/// Hard ceiling on socket bytes consumed by one fixed-tick poll. Four maximum
+/// UDP datagrams fit, while normal MTU-sized traffic reaches the packet ceiling
+/// first.
+const MAX_POLL_BYTES: usize = 256 * 1024;
+
+/// One datagram can emit at most a disconnect plus a replacement connect.
+const MAX_POLL_SOCKET_EVENTS: usize = MAX_POLL_PACKETS * 2;
+
+/// Timeout cleanup gets a reserved slice of every poll's event budget, so a
+/// socket flood cannot indefinitely retain expired peers.
+const MAX_POLL_TIMEOUT_EVENTS: usize = 64;
+
+/// Hard ceiling on live-peer slots inspected by one fixed-tick poll. At the
+/// maximum supported 65,535 peers, the round-robin cursor covers every slot in
+/// fewer than 128 ticks.
+const MAX_POLL_MAINTENANCE_SCANS: usize = 512;
+
+/// Hard ceiling on host keepalive datagrams sent by one fixed-tick poll.
+const MAX_POLL_KEEPALIVE_SENDS: usize = MAX_POLL_MAINTENANCE_SCANS;
+
+/// Hard ceiling on expired reconnect watermarks removed by one fixed-tick poll.
+const MAX_POLL_WATERMARK_CLEANUPS: usize = MAX_POLL_MAINTENANCE_SCANS;
+
+/// Hard ceiling on the event vector returned by one poll.
+const MAX_POLL_EVENTS: usize = MAX_POLL_SOCKET_EVENTS + MAX_POLL_TIMEOUT_EVENTS;
/// Idle timeout: a peer that sends nothing for this long is dropped
/// (`Timeout`). Mirrors LiteNetLib's default 5 s disconnect timeout.
@@ -35,6 +63,12 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
/// How often the host sends its own Ping to each peer to measure RTT.
const PING_INTERVAL: Duration = Duration::from_secs(1);
+/// Default hard ceiling on live transport peers.
+pub const DEFAULT_MAX_PEERS: usize = 1_024;
+
+/// Default hard ceiling on live transport peers sharing one source IP.
+pub const DEFAULT_MAX_PEERS_PER_IP: usize = 16;
+
/// .NET `DateTime` ticks (100 ns units) at the Unix epoch (1970-01-01).
const UNIX_EPOCH_TICKS: i64 = 621_355_968_000_000_000;
@@ -61,8 +95,20 @@ pub enum Event {
Disconnected(PeerId, DisconnectReason),
}
+#[derive(Debug, Clone, Copy, Default)]
+struct PollWork {
+ packets: usize,
+ bytes: usize,
+ socket_events: usize,
+ timeout_events: usize,
+ maintenance_scans: usize,
+ keepalive_sends: usize,
+ watermark_cleanups: usize,
+}
+
struct Peer {
id: PeerId,
+ slot: usize,
addr: SocketAddr,
connect_time: i64,
connection_number: u8,
@@ -74,31 +120,80 @@ struct Peer {
rtt: Option,
}
+struct ReconnectWatermark {
+ connect_time: i64,
+ expires_at: Instant,
+ generation: u64,
+}
+
/// A UDP host: binds a socket, tracks peers, and exposes an event/send API.
pub struct Host {
socket: UdpSocket,
peers: HashMap,
- by_id: HashMap,
- next_id: PeerId,
- next_local_peer_id: i32,
+ peer_slots: Vec