reliable.cs
August 26, 2026 · View on GitHub
If this library helps you, please support it: Become a supporter
Status: released — v1.0.0, the first release. Wire and behavior compatibility with the C reference is proven in CI on every push (the "C wire + behavior compatibility" gate).
C# port of the reliable C library: packet acknowledgement, fragmentation/reassembly, and rtt/jitter/packet loss/ bandwidth estimation over an unreliable datagram transport (UDP). It does not retransmit: it tells you which packets arrived; what you do about the ones that did not is your business.
The wire format is byte-identical to the C library — STANDARD.md is vendored
verbatim from the C repo — and the protocol state machines match it observable
behavior for observable behavior. That is proven, not asserted: golden wire
vectors are pinned from the real C library into the test suite, and the interop
gate in compat/ runs this code head-to-head against the real reliable.c —
same scripted scenarios, same shared PRNG, every transmitted byte, every
delivered packet, every ack, every counter and every stat compared bit for bit,
through clean, lossy, hostile (corrupting) and sequence-wrap link profiles
including 5000-iteration differential soaks.
Family values (shared with serialize.cs): zero third-party dependencies,
including test frameworks; hostile wire data never throws — malformed, stale,
duplicate and truncated packets fail into counters and are dropped, exactly like
the C library; exceptions are reserved for API misuse; no unsafe code; zero
allocation at steady state everywhere the C library is zero-alloc (new fragment
reassemblies rent from ArrayPool, the one place the C library mallocs).
Layout
src/Reliable.cs— the whole library, one file (mirrors the C single translation unit):Endpoint,EndpointConfig,EndpointCounter, the transmit/process callback delegates,ReliableLog, plus the internal sequence buffer and wire codecs.tests/— console test runner, no test framework: prints each test name, exit code is the verdict. The C suite ported test for test, plus golden wire pins generated from the C library, hostile-input tests for every rejection rule, a zero-allocation proof, the C repo's fuzz_target.c script harness, and a seeded adversarial soak.compat/— the cross-implementation interop harness:Compat.csproj(C# half) andc/compat.c(C half, built against the realreliable.c).scripts/interop.sh— the interop gate as one runnable command.STANDARD.md— the wire format spec, vendored verbatim from the C repo (CI diffs it against upstream and fails on drift).notes/— the port map (every C construct → its C# shape, the invariant register) and the red-team brief.
Build and test
dotnet build src/Reliable.csproj # builds net8.0 + net10.0
dotnet run --project tests/Tests.csproj -f net10.0 # add "short" for a quicker pass
dotnet run --project tests/Tests.csproj -f net8.0 # the LTS leg (needs the .NET 8
# runtime, or DOTNET_ROLL_FORWARD=LatestMajor)
dotnet run --project tests/Tests.csproj -f net10.0 -- golden # run only tests matching a substring
Interop gate (head-to-head vs the C library)
scripts/interop.sh path/to/reliable # defaults to ../reliable
Builds compat/c/compat.c against the real reliable.c, then runs both halves
across wire-vector and scripted-scenario modes and diffs the outputs; any byte
of difference fails. Two requirements the script enforces and CI pins:
-ffp-contract=offon the C build is required, not optional: C# always evaluates float expressions strictly, and default clang/gcc on ARM64 contract the stats smoothing (a + b*c) into fused multiply-adds that drift by 1 ulp and fail the bit-exact stats comparison. (The same rule as the serialize.cs interop gate.)- CI pins the C clone to a release tag (
v1.4.0); bump deliberately, in its own commit.
Using it
using Reliable;
var endpoint = new Endpoint(new EndpointConfig
{
Name = "client",
TransmitPacket = (id, sequence, packetData) => socket.Send(packetData),
ProcessPacket = (id, sequence, packetData) => { HandleGamePacket(packetData); return true; },
}, time);
// per frame:
endpoint.SendPacket(payload); // fragments automatically above FragmentAbove
endpoint.ReceivePacket(datagram); // for every datagram off the socket
endpoint.Update(time);
foreach (ushort acked in endpoint.GetAcks()) { /* that packet arrived */ }
endpoint.ClearAcks();
The defaults in EndpointConfig are the C library's reliable_default_config:
sensible for a client/server game exchanging packets at 60HZ. One endpoint per
connection: a client has one, a server has one per client slot.
Caveats (the C library's, unchanged)
- The transmit callback must not send packets on the same endpoint — it is called synchronously while the endpoint's transmit scratch buffer is in use. Sending from the process callback is fine, as is sending on other endpoints.
- Acks are dropped once the ack buffer fills — call
ClearAcks()once per frame. A dropped ack can still be reported by a later packet while it remains within the 32-packet ack window. - Not thread safe. Endpoints have no locking, and the log level/writer are process-wide. One endpoint per thread, or protect each endpoint with your own lock.
- No authentication, no encryption. reliable assumes an authenticated, encrypted transport beneath it — that is netcode's job (its sister library).
- Fragmentation amplifies loss by design: one lost fragment loses the whole packet. Callers needing large reliable blocks should build block transfer above this rather than sending very large packets.
- Continuous bidirectional exchange is assumed (~60HZ both ways): acks piggyback on outgoing packets, and there are no keepalives because there are never lulls. The rtt/jitter/loss stats are fresh under the same assumption.
Where the C# API deliberately differs
The wire and the semantics are the C library's; the surface is what a careful
C# programmer would expect. The differences, all recorded in
notes/port-map.md:
Span<byte>replaces pointer+length; delegates close over your state, so there is novoid* context; the GC replaces the custom allocator hooks.- The C library's debug-only configuration asserts are construction-time
ArgumentExceptions here — validated once, atnew Endpoint(...), never per packet. That includes the send path's fragment-count assert, checked at construction as "MaxFragments must cover MaxPacketSize". - Empty payloads throw (the C library asserts
packet_bytes > 0); oversized payloads are dropped and counted, exactly like C. ProcessPacketreceives a read-only span (the C callback gets a mutable pointer); copy if you need to mutate.- No
Dispose: the endpoint owns no unmanaged resources.Reset()matchesreliable_endpoint_reset(and like it, does not clear smoothed stats).
License
AGPL-3.0 for now (see LICENSE); intended to move to MBSL when ready. The reference C implementation is BSD-3-Clause.