soe-protocol
A Rust implementation of version 3 of the SOE (Sony Online Entertainment) network protocol.
SOE is a UDP transport layer used by a number of games (Free Realms, H1Z1, Landmark, PlanetSide 2, and others). On top of raw UDP it adds:
- Sessions with a negotiated handshake, heartbeats, and inactivity timeouts.
- Packet verification via CRC32.
- Reliable, ordered delivery with fragmentation and reassembly (a sliding-window reliable data channel in each direction).
- Optional compression (zlib) of contextual packets.
- Optional encryption (RC4) of application data.
This implementation is an AI-assisted port informed by the public C# and Zig implementations in Sanctuary.SoeProtocol.
While porting, the protocol behaviour was re-derived from the reference rather than copied, which surfaced a few improvements over it:
- Runtime-agnostic core: The protocol logic is a pure state machine with no I/O or runtime dependency. Time is passed in explicitly, datagrams are fed and drained as buffers, and runtime adapters (Tokio, blocking, or your own) sit on top. The reference couples the protocol to its host runtime.
- Sequence-wraparound fix: The reliable-data ack-all throttle compared a truncated 16-bit wire sequence against a full-width counter, so after 65,536 packets the throttle broke and the channel spammed acknowledgements every tick. This bug is present in both the C# and Zig references; here sequences are tracked at full width and truncated only on the wire.
- Hardened fragment reassembly: Master-fragment parsing is guarded against hostile
input: short fragments no longer panic, and the attacker-controlled reassembly length
can no longer trigger a multi-gigabyte preallocation (both are bounded and answered
with a
CorruptPacketdisconnect). The reference shares this gap. - Multi-packet short-circuit: Processing a bundled multi-packet now stops as soon as a sub-packet terminates the session, instead of continuing to act on later sub-packets of an already-closed session.
- Idiomatic, defensive Rust API: Public types implement
Debug(with the RC4 key state redacted), data-enqueue calls are#[must_use]so dropped payloads can't pass silently, and the parse paths are exercised by an end-to-end fuzz suite and the ported regression tests.
Design: an I/O-agnostic core
The crate is structured as an I/O-agnostic core: all protocol logic is a pure state
machine that performs no I/O and reads no clock. Time is supplied by the caller as a
std::time::Instant, and bytes are handed in and out explicitly. This keeps the core
runtime-agnostic, deterministic, and easy to test, with thin adapters layered on top
for real-world I/O.
┌─────────────────────────── adapters (opt-in) ───────────────────────────┐
│ SyncSoeSocket (std) TokioSoeSocket (feature = "tokio") │
│ TokioSoeServer + SoeHandle (feature = "tokio") │
└──────────────────────────────────┬───────────────────────────────────────┘
│ drives
┌───────────────────────────────────▼──────────────────────────────────────┐
│ SoeMultiplexer<A> — demultiplexes many sessions by remote address │
│ SoeSession — one session's state machine │
│ channels / packets / crc32 / rc4 / zlib / varint — protocol primitives │
└───────────────────────────────────────────────────────────────────────────┘
SoeSession— the state machine for a single session: handshake, reliable channels, heartbeats, and termination.SoeMultiplexer<A>— demultiplexes datagrams from many remotes (generic over the address typeA) into per-sessionSoeSessions. You feed it incoming datagrams and ticks; it surfaces datagrams to send and lifecycle/data events.- Adapters — optional convenience drivers that own a real socket and pump the
core. The default build pulls in zero async dependencies; the Tokio adapters are
gated behind the
tokiofeature.
Installation
[]
= "0.1"
# For the async (Tokio) adapters:
= { = "0.1", = ["tokio"] }
Requires Rust 1.88+ (edition 2024).
Quick start
Configure a socket with the application protocol both peers agree on, then drive it. The synchronous adapter needs no extra dependencies:
use Duration;
use ;
use ;
let config = SocketConfig ;
// Bind and tick every 5ms.
let mut socket = bind?;
loop
# Ok::
To act as a client, call socket.connect(server_addr) instead of waiting for an
inbound session.
Writing a game server
UDP has no per-connection socket: every client's datagrams arrive on the one bound socket, and a SOE session is inherently single-owner (sequence numbers, RC4 cipher state, and fragment reassembly must be mutated by one task at a time). So rather than a socket-per-client task as you might use with TCP, the recommended shape is one driver task that owns the socket and all protocol state, with per-client game logic running on its own tasks, talking to the driver over channels.
The tokio feature provides this out of the box via TokioSoeServer and its
cloneable SoeHandle:
use HashMap;
use SocketAddr;
use Duration;
use Bytes;
use SessionParameters;
use ;
use ;
use mpsc;
async
// Per-client game logic runs concurrently and replies via the shared handle.
async
SoeHandle is Clone/Send and exposes connect, enqueue_data, and terminate;
all are non-blocking and simply post a command to the driver loop. Events are received
in an order that guarantees a session's SessionOpened is surfaced before any of
its DataReceived, and SessionClosed after — so per-session state (like the task
spawned above) is always in place before that session's data arrives.
Scaling across cores
A single UDP receive loop comfortably dispatches far more packets per second than a
game simulation typically consumes, so one TokioSoeServer is usually plenty. If
profiling ever shows the I/O task saturating a core, scale out by running several
servers — one per SO_REUSEPORT socket — and routing by client address. Because each
server owns its own socket and SoeMultiplexer, this requires no changes to the core.
Examples
Runnable examples live in examples/:
| Example | Feature | Description |
|---|---|---|
server-sync / client-sync |
— | Blocking, std-only echo server and ping client. |
server-tokio / client-tokio |
tokio |
Async echo server and ping client. |
server-actor |
tokio |
Game-server skeleton: per-client-task fan-out. |
Run a ping-pong over real UDP:
# std-only
# Tokio
# Actor-style game server
Bring your own runtime
You don't need either bundled adapter. The core, SoeMultiplexer, has no I/O
dependency: feed it incoming datagrams with process_incoming(remote, datagram, now),
call run_tick(now) periodically, and flush whatever take_outgoing() returns over
your own socket, reading events from take_events(). The UdpTransport trait and
SoeMultiplexer::drive offer a minimal, dependency-free seam for any non-blocking UDP
socket (with a blanket impl for std::net::UdpSocket).
License
Licensed under GPL-3.0-or-later. See LICENSE.