Replace timeout-polled in-world UDP receives with one cancellable caller-buffered socket operation. Transfer only right-sized pooled datagrams through the FIFO, return every ownership edge deterministically, and send caller spans without a transport copy while preserving handshake pacing and ACK order.
2.9 KiB
Slice H-c1 — cancellable pooled receive owner
Result
The in-world transport now has one asynchronous socket receive owner:
one retained full-size socket buffer
-> ReceiveFromAsync(cancellation token)
-> copy actual datagram length into a right-sized ArrayPool bucket
-> FIFO Channel<PooledInboundDatagram>
-> render-thread decode/dispatch
-> return bucket exactly once
The synchronous PumpOnce handshake path keeps its existing 250 ms pacing,
but receives into a temporarily rented caller buffer rather than accepting a
fresh UdpClient.Receive array. The normal in-world path no longer wakes four
times per idle second and no longer uses SocketException(TimedOut) as its
heartbeat.
NetClient.Send also consumes its caller's ReadOnlySpan<byte> directly
through Socket.SendTo; the transport-only datagram.ToArray() copy is gone.
Higher-level outbound framing allocations are intentionally left to H-c3.
Retail and protocol boundary
Behavioral invariants remain unchanged:
- one outstanding receive;
- kernel arrival order is FIFO channel order;
- decode, ISAAC, fragment assembly, ACK decisions, and event dispatch remain on the existing render/update thread;
- ACK generation remains inside
ProcessDatagram, immediately after decode acceptance; LinkStatusHolder::OnHeartbeat(0x004113D0) last-heard placement remains immediately after successful decode;- blocking
PumpOncehandshake cadence is unchanged.
The initial design review caught that 484 bytes is a fragment limit, not a
safe UDP receive limit. Named-retail CNetLayerPacket (type 3705 in
acclient.h) contains a separate ProtoHeader plus m_Data[65484].
Accordingly the socket edge retains a full UDP buffer. Only the received byte
count crosses the queue boundary, preventing small queued packets from each
pinning a 64 KiB bucket.
Holtburger's receive/finalize path remains the cross-reference for ACK-per-accepted-packet ordering. The transport ownership mechanism is a modern .NET adaptation; it does not change AC wire behavior.
Deterministic evidence
- real loopback
ReceiveAsyncwrites a 1,500-byte datagram into caller-owned memory; - cancelling an idle receive terminates the one outstanding operation;
- a scripted recoverable
SocketExceptiondoes not kill the receive owner; - three accepted packets retain arrival order and produce ACKs in the exact
sequence
41, 42, 43; - blocking handshake timeout behavior and cached socket-timeout tests remain unchanged;
- graceful confirmation drain releases every consumed pooled datagram, and shutdown cancels/joins the receive task before returning unread buffers.
Remaining H-c work
H-c2 removes Packet, BodyBytes, PacketHeaderOptional, List, and
single-fragment payload copies from the production decode path using borrowed
memory with an explicit lifetime boundary. H-c3 writes fragment and packet
framing directly into caller storage.