soe-protocol 1.0.0

Sony Online Entertainment protocol implementation in Rust.
Documentation

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 CorruptPacket disconnect). 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 type A) into per-session SoeSessions. 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 tokio feature.

Installation

[dependencies]
soe-protocol = "0.1"

# For the async (Tokio) adapters:
soe-protocol = { version = "0.1", features = ["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 std::time::Duration;
use soe_protocol::{SessionParameters, SyncSoeSocket};
use soe_protocol::socket::{SocketConfig, SocketEvent, SoeSocket};

let config = SocketConfig {
    default_session_params: SessionParameters {
        application_protocol: "MyGame".to_owned(),
        ..SessionParameters::default()
    },
    ..SocketConfig::default()
};

// Bind and tick every 5ms.
let mut socket = SyncSoeSocket::bind("0.0.0.0:20260".parse().unwrap(), config, Duration::from_millis(5))?;

loop {
    // One read-or-tick cycle; returns any events produced.
    for event in socket.step()? {
        match event {
            SocketEvent::SessionOpened { remote } => println!("opened {remote}"),
            SocketEvent::DataReceived { remote, data } => {
                socket.enqueue_data(&remote, &data); // echo it back
            }
            SocketEvent::SessionClosed { remote, reason } => println!("closed {remote}: {reason:?}"),
        }
    }
}
# Ok::<(), std::io::Error>(())

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 std::collections::HashMap;
use std::net::SocketAddr;
use std::time::Duration;
use bytes::Bytes;
use soe_protocol::SessionParameters;
use soe_protocol::socket::{SocketConfig, SocketEvent};
use soe_protocol::tokio_rt::{SoeHandle, TokioSoeServer};
use tokio::sync::mpsc;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let config = SocketConfig {
        default_session_params: SessionParameters {
            application_protocol: "MyGame".to_owned(),
            ..SessionParameters::default()
        },
        ..SocketConfig::default()
    };

    // The driver task owns the socket and all protocol state.
    let mut server = TokioSoeServer::bind("0.0.0.0:20260".parse().unwrap(), config, Duration::from_millis(5)).await?;

    // One inbound channel per connected client; each client task owns its receiver.
    let mut clients: HashMap<SocketAddr, mpsc::UnboundedSender<Bytes>> = HashMap::new();

    while let Some(event) = server.recv_event().await {
        match event {
            SocketEvent::SessionOpened { remote } => {
                let (tx, rx) = mpsc::unbounded_channel();
                clients.insert(remote, tx);
                tokio::spawn(client_task(remote, server.handle(), rx));
            }
            SocketEvent::DataReceived { remote, data } => {
                if let Some(tx) = clients.get(&remote) {
                    let _ = tx.send(data); // route to that client's task
                }
            }
            SocketEvent::SessionClosed { remote, .. } => {
                clients.remove(&remote);
            }
        }
    }
    Ok(())
}

// Per-client game logic runs concurrently and replies via the shared handle.
async fn client_task(remote: SocketAddr, handle: SoeHandle, mut inbound: mpsc::UnboundedReceiver<Bytes>) {
    while let Some(data) = inbound.recv().await {
        handle.enqueue_data(remote, data); // echo
    }
}

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
cargo run --example server-sync -- 127.0.0.1:20260
cargo run --example client-sync -- 127.0.0.1:20260

# Tokio
cargo run --features tokio --example server-tokio -- 127.0.0.1:20260
cargo run --features tokio --example client-tokio -- 127.0.0.1:20260

# Actor-style game server
cargo run --features tokio --example server-actor -- 127.0.0.1:20260
cargo run --features tokio --example client-tokio -- 127.0.0.1:20260

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.