acdream/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs
Erik 43e60a6971 feat(net): N1 - outbound sent-packet cache + resend on NAK
Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.

New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
  counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
  `intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
  stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
  @ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
  optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
  @ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
  pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
  @ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
  pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
  store, the wrap-safe sorted dedup pending-resend list
  (FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
  Cache commit happens AFTER a successful send
  (FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
  NAK ids[0] folds into the watermark as retail's implicit cumulative ack
  (RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
  20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
  with fragments), Time = current interval id, Sequence/Id/Iteration/
  DataSize verbatim, checksum = fresh header hash + stored sealed checksum
  (FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
  original ISAAC key rides inside the sealed value - no new keystream word
  is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
  path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
  prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
  this slice.
- TransportStats: unconditional counters (ResendsSent,
  NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.

PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.

WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.

Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.

N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.

Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.

Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:21:37 +02:00

1500 lines
64 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Buffers.Binary;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// Termination causes the double can hit, mirroring ACE's
/// <c>SessionTerminationReason</c> names for the modeled paths.
/// </summary>
internal enum AceTerminationReason
{
None,
/// <summary>NetworkSession.cs:312-315 — client sent a Disconnect header.</summary>
PacketHeaderDisconnect,
/// <summary>NetworkSession.cs:318-321 — client sent NetErrorDisconnect.</summary>
ClientSentNetworkErrorDisconnect,
/// <summary>NetworkSession.cs:393-397 — sequence gap beyond the crypto search window.</summary>
AbnormalSequenceReceived,
/// <summary>TimeoutTick (NetworkSession.cs:88, Session.cs:140-144) expired — every ACE transport death is silence.</summary>
NetworkTimeout,
}
/// <summary>
/// ACE's <c>SessionState</c> (Network/Enum/SessionState.cs), reduced to the
/// three states a transport-level conversation can reach. The gate that reads
/// it is <c>Session.CheckState</c> (Session.cs:93-105).
///
/// <para>
/// Not modeled: <c>WorldConnected</c> (set by CharacterHandler.cs:260 — it
/// gates gameplay handlers, never the transport pipeline) and
/// <c>TerminationStarted</c> (Session.cs:128; only reachable with
/// <c>PendingTermination</c> already set, and the only code reading it,
/// Session.cs:136-137, is unreachable while it is).
/// </para>
/// </summary>
internal enum AceSessionState
{
/// <summary>Pre-login. CheckState drops Ack/TimeSync/Echo/Flow here.</summary>
AuthLoginRequest,
/// <summary>ConnectRequest sent, waiting for the ConnectResponse (AuthenticationHandler.cs:232).</summary>
AuthConnectResponse,
/// <summary>Handshake complete (NetworkManager.cs:77).</summary>
AuthConnected,
}
/// <summary>ACE's <c>SessionTerminationPhase</c> (Network/Enum/SessionTerminationPhase.cs).
/// <c>WorldManagerWorkCompleted</c> is a post-drop bookkeeping marker
/// (NetworkManager.cs:369) with no transport-visible effect and is not
/// modeled.</summary>
internal enum AceTerminationPhase
{
/// <summary>Session.cs:126-131 — the ~2 s window in which inbound and outbound still run.</summary>
Initialized,
/// <summary>Session.cs:130-131 — the window elapsed; WorldManager may now DropSession.</summary>
SessionWorkCompleted,
}
/// <summary>
/// Transport-free model of ACE's per-connection <c>Session</c> +
/// <c>NetworkSession</c> receive + send behavior, operating on raw datagrams
/// (byte[]). This is the Campaign N test double: slices N1-N5 are graded
/// against it, so every rule carries its citation into
/// <c>references/ACE/Source/ACE.Server/Network/NetworkSession.cs</c> (or the
/// named ACE file). It deliberately reproduces ACE's raw (wrap-unsafe)
/// sequence comparisons and the exact-equality flag checks — do NOT "fix"
/// them; they are the environment acdream must survive.
///
/// <para>
/// Time comes exclusively from an injected <see cref="VirtualClock"/> — no
/// wall clock anywhere. The model is single-threaded by design; callers
/// (see <see cref="FakeAceTransport"/>) serialize access.
/// </para>
///
/// <para>
/// <b>Not an independent wire oracle.</b> C2S CRC verification reuses
/// acdream's own <see cref="PacketHeaderOptional"/> to parse and hash the
/// optional-header section, so the double is blind to any bug SHARED by our
/// encoder and our parser: an optional section we write wrong and read back
/// wrong still checksums correctly here, while real ACE would drop it. Two
/// asymmetries against ACE's <c>PacketHeaderOptional.Unpack</c> are known and
/// deliberate:
/// <list type="bullet">
/// <item>ACE has NO inbound ConnectRequest branch (PacketHeaderOptional.cs:30-124
/// goes straight from AckSequence to LoginRequest); acdream decodes a
/// 32-byte section (PacketHeaderOptional.cs:139-150) because we are the
/// client. A C2S packet carrying that flag would hash differently on
/// real ACE.</item>
/// <item>ACE hashes-but-does-not-advance the reader for LoginRequest
/// (PacketHeaderOptional.cs:78), WorldLoginRequest (:86) and
/// ConnectResponse (:94); acdream advances past WorldLoginRequest and
/// ConnectResponse (PacketHeaderOptional.cs:130-133, :152-155). The
/// hashed bytes match; only the fragment-loop start offset differs, and
/// only for packets carrying those flags plus fragments (none exist).</item>
/// </list>
/// The independent oracle for optional-header wire layout stays the live-ACE
/// connected gate, not this double.
/// </para>
///
/// <para>
/// Intentional simplifications, none affecting the pinned rules:
/// <list type="bullet">
/// <item>The initial timeout horizon is the 60 s in-world value; ACE's
/// 15 s pre-auth window (NetworkSession.cs:102-103) is not modeled.
/// Timeout expiry is checked in <see cref="Update"/> (ACE checks
/// <c>TimeoutTick</c> from Session.TickOutbound, Session.cs:140-144).</item>
/// <item>The 5 ms inter-bundle pacing delay (NetworkSession.cs:30, :244)
/// is not modeled — it is send pacing, not protocol behavior, and
/// would deadlock a virtual clock that only tests advance.</item>
/// <item>Ack/TimeSync/EchoResponse/message-bundle emission is gated on the
/// handshake being complete (ACE cannot address S2C traffic before it
/// knows the endpoint; pre-handshake the timers cannot have fired in
/// practice). Queued raw packets (ConnectRequest, NAK,
/// RejectRetransmit) flush regardless, like ACE's FlushPackets.</item>
/// <item><c>VerifyEcho</c>'s speed-hack detector (NetworkSession.cs:593-647)
/// is not modeled: it can log off a player but never terminates the
/// transport session.</item>
/// <item>The inbound retransmit/reject list cap of 1024 ids that acdream's
/// parser enforces (PacketHeaderOptional.cs:82, :96) does not exist in
/// ACE (PacketHeaderOptional.cs:35-60). It is unreachable rather than
/// wrong: ACE reads into a 1024-byte buffer (ConnectionListener.cs:26,
/// ClientPacket.cs:14), so the widest list a C2S datagram can carry is
/// (1024 20 header 4 count) / 4 = 250 ids.</item>
/// </list>
/// </para>
/// </summary>
internal sealed class AceSessionModel
{
// ---- ACE constants, cited ----
/// <summary>NetworkSession.cs:381 — max NAK ids per RequestRetransmit.</summary>
private const int MaxNumNakSeqIds = 115;
/// <summary>ServerPacket.cs:11 — the S2C body budget after the 20-byte header.</summary>
private const int MaxPacketSize = 464;
/// <summary>GameMessageGroup.cs:18 — the bundle array length.</summary>
private const int QueueMax = 0x0C;
/// <summary>NetworkSession.cs:359 — `new TimeSpan(0, 0, 1)` NAK rate limit.</summary>
private static readonly long NakRateLimitTicks = TimeSpan.FromSeconds(1).Ticks;
/// <summary>NetworkSession.cs:32 — timeBetweenAck = 2000 ms.</summary>
private static readonly long AckIntervalTicks = TimeSpan.FromSeconds(2).Ticks;
/// <summary>NetworkSession.cs:31 — timeBetweenTimeSync = 20000 ms.</summary>
private static readonly long TimeSyncIntervalTicks = TimeSpan.FromSeconds(20).Ticks;
/// <summary>NetworkManager.DefaultSessionTimeout (60 s), applied at NetworkSession.cs:329-331.</summary>
private static readonly long SessionTimeoutTicks = TimeSpan.FromSeconds(60).Ticks;
/// <summary>NetworkSession.cs:67 — cachedPacketPruneInterval = 5 s.</summary>
private static readonly long CachePruneIntervalTicks = TimeSpan.FromSeconds(5).Ticks;
/// <summary>NetworkSession.cs:72 — cachedPacketRetentionTime = 120 s.</summary>
private const int CachedPacketRetentionSeconds = 120;
/// <summary>SessionTerminationDetails.cs:12 — TerminationEndTicks = start + 2 s.</summary>
private static readonly long TerminationWindowTicks = TimeSpan.FromSeconds(2).Ticks;
// ---- identity / handshake material ----
private readonly VirtualClock _clock;
private readonly ushort _serverId;
private readonly uint _clientId;
private readonly ulong _cookie;
private readonly uint _clientSeed;
private readonly uint _serverSeed;
/// <summary>C2S verifier — SessionConnectionData.CryptoClient (SessionConnectionData.cs:61).</summary>
public AceCryptoModel Crypto { get; }
/// <summary>S2C keystream — SessionConnectionData.IssacServer (SessionConnectionData.cs:62).</summary>
private readonly IsaacRandom _s2cKeystream;
// ---- receive state ----
/// <summary>NetworkSession.cs:57 — starts at 1.</summary>
private uint _lastReceivedPacketSequence = 1;
/// <summary>NetworkSession.cs:58 — starts at 0.</summary>
private uint _lastReceivedFragmentSequence;
/// <summary>NetworkSession.cs:41 — outOfOrderPackets (parsed + CRC-verified; never re-verified).</summary>
private readonly Dictionary<uint, ParsedPacket> _outOfOrderPackets = new();
/// <summary>NetworkSession.cs:42 — partialFragments (multi-fragment C2S reassembly).</summary>
private readonly Dictionary<uint, PartialC2SMessage> _partialFragments = new();
/// <summary>NetworkSession.cs:43 — outOfOrderFragments (the C2S fragment gate buffer).</summary>
private readonly Dictionary<uint, byte[]> _outOfOrderFragments = new();
/// <summary>NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null).</summary>
private long? _lastNakTimestamp;
// ---- send state ----
/// <summary>
/// ACE's ConnectionData.PacketSequence is UIntSequence(clientPrimed:false)
/// (SessionConnectionData.cs:66): CurrentValue starts at uint.MaxValue and
/// the first NextValue wraps to 0 (UIntSequence.cs:19-41), so the
/// cleartext ConnectRequest goes out with sequence 0. The first ENCRYPTED
/// flush re-primes CurrentValue to 1 (NetworkSession.cs:716-717), making
/// the first encrypted S2C packet sequence 2.
/// </summary>
private uint _packetSequence = uint.MaxValue;
/// <summary>SessionConnectionData.cs:36 — FragmentSequence, default 0; assigned at bundle flush (NetworkSession.cs:821).</summary>
private uint _s2cFragmentSequence;
/// <summary>NetworkSession.cs:65 — cachedPackets, keyed by sequence.</summary>
private readonly Dictionary<uint, CachedS2CPacket> _cachedPackets = new();
private long? _lastPruneTimestamp;
private long _nextAckTimestamp;
private long? _nextResyncTimestamp;
private bool _sendResync;
/// <summary>NetworkSession.cs:38-39 — one NetworkBundle per GameMessageGroup.</summary>
private readonly PendingBundle[] _bundles = new PendingBundle[QueueMax];
/// <summary>NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update.</summary>
private readonly Queue<OutboundDraft> _flushQueue = new();
// ---- termination state ----
private long _terminationEndTimestamp;
// ---- observable outputs ----
private readonly List<byte[]> _dispatchedMessages = new();
private readonly List<byte[]> _sentDatagrams = new();
private readonly Queue<byte[]> _pendingOutbound = new();
public AceSessionModel(
VirtualClock clock,
uint clientSeed,
uint serverSeed,
uint clientId,
ulong cookie,
ushort serverId = 0x000C)
{
_clock = clock;
_clientSeed = clientSeed;
_serverSeed = serverSeed;
_clientId = clientId;
_cookie = cookie;
_serverId = serverId;
Crypto = new AceCryptoModel(clientSeed);
Span<byte> seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, serverSeed);
_s2cKeystream = new IsaacRandom(seedBytes);
for (int i = 0; i < _bundles.Length; i++)
_bundles[i] = new PendingBundle(); // NetworkSession.cs:105-109
// NetworkSession.cs:54-55 — sendAck starts true with the 2 s delay armed.
_nextAckTimestamp = clock.GetTimestamp() + AckIntervalTicks;
// Simplified from NetworkSession.cs:102-103 (15 s pre-auth window);
// the double pins the 60 s in-world horizon of :329-331 only.
TimeoutDeadlineTimestamp = clock.GetTimestamp() + SessionTimeoutTicks;
}
// ---- diagnostics for assertions ----
/// <summary>Session.State (Session.cs:36) — the value CheckState reads.</summary>
public AceSessionState State { get; private set; } = AceSessionState.AuthLoginRequest;
public uint LastReceivedPacketSequence => _lastReceivedPacketSequence;
public uint LastReceivedFragmentSequence => _lastReceivedFragmentSequence;
/// <summary>Fully-assembled C2S message bodies in ACE dispatch order.</summary>
public IReadOnlyList<byte[]> DispatchedMessages => _dispatchedMessages;
/// <summary>Every S2C datagram the model has emitted, in send order (cumulative).</summary>
public IReadOnlyList<byte[]> SentDatagrams => _sentDatagrams;
/// <summary>
/// True once <c>Session.Terminate</c> has armed PendingTermination
/// (Session.cs:281-298). Inbound and outbound keep running for the ~2 s
/// termination window — see <see cref="IsReleased"/> for the point of no
/// return.
/// </summary>
public bool IsTerminated => TerminationPhase is not null;
/// <summary>Session.PendingTermination.TerminationStatus (Session.cs:56, :126-131).</summary>
public AceTerminationPhase? TerminationPhase { get; private set; }
/// <summary>
/// NetworkSession.isReleased (:952, :958-974) — set by Session.DropSession
/// (:300-334) once the termination window closed. From here every inbound
/// packet and every pump is ignored.
/// </summary>
public bool IsReleased { get; private set; }
public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None;
/// <summary>VirtualClock timestamp at or past which <see cref="Update"/> terminates the session.</summary>
public long TimeoutDeadlineTimestamp { get; private set; }
public int OutOfOrderPacketCount => _outOfOrderPackets.Count;
/// <summary>Completed messages parked behind the C2S fragment gate (NetworkSession.cs:539-542).</summary>
public int FragmentGateBufferCount => _outOfOrderFragments.Count;
public int PartialFragmentBufferCount => _partialFragments.Count;
public int CachedPacketCount => _cachedPackets.Count;
public IReadOnlyCollection<uint> CachedPacketSequences => _cachedPackets.Keys;
/// <summary>Packets silently dropped by CRC/Search failure (NetworkSession.cs:277-280).</summary>
public int CrcDropCount { get; private set; }
/// <summary>Packets dropped by the duplicate-rejection rule (NetworkSession.cs:342-347).</summary>
public int DuplicateDropCount { get; private set; }
/// <summary>Packets dropped by the Session.CheckState gate (Session.cs:93-110) — before CRC.</summary>
public int StateDropCount { get; private set; }
public int RetransmitsServed { get; private set; }
// ---- script hooks for FakeAceTransport ----
/// <summary>Fired when a LoginRequest packet is handled (NetworkSession.cs:463-468).</summary>
public event Action? LoginRequestReceived;
/// <summary>Fired when a cookie-matching ConnectResponse is accepted (NetworkManager.cs:50-79).</summary>
public event Action? ConnectResponseAccepted;
/// <summary>Fired per dispatched C2S message body, in ACE dispatch order.</summary>
public event Action<byte[]>? MessageDispatched;
/// <summary>Drain the datagrams emitted since the last call, in send order.</summary>
public List<byte[]> TakePendingDatagrams()
{
var drained = new List<byte[]>(_pendingOutbound.Count);
while (_pendingOutbound.TryDequeue(out byte[]? datagram))
drained.Add(datagram);
return drained;
}
// =====================================================================
// Receive pipeline — Session.ProcessPacket (Session.cs:107-113) then
// NetworkSession.ProcessPacket (:269-379), in ACE's exact order.
// =====================================================================
public void Receive(ReadOnlySpan<byte> datagram)
{
if (IsReleased)
return; // isReleased guard (:271-272)
if (!TryParse(datagram, out ParsedPacket packet))
return; // ClientPacket.Unpack failure — ConnectionListener discards silently
// 0. Session.CheckState (Session.cs:93-110) runs BEFORE
// NetworkSession.ProcessPacket — so before VerifyCRC. A packet
// dropped here costs ZERO keystream: ACE never looked at its
// checksum.
if (!CheckState(packet.Header))
{
StateDropCount++;
return;
}
// ConnectResponse is routed by flag BEFORE the session pipeline
// (NetworkManager.cs:50-79): its CRC is never verified (VerifyCRC only
// runs inside NetworkSession.ProcessPacket) and no dedup/watermark
// applies — the 64-bit cookie is the authenticator
// (PacketInboundConnectResponse). The state requirement is identical
// on both paths (NetworkManager.cs:64 vs Session.cs:98-99), so running
// CheckState first is faithful for the double's single-listener shape.
if ((packet.Header.Flags & PacketHeaderFlags.ConnectResponse) != 0)
{
HandleConnectResponse(packet);
return;
}
// 1. CRC verification (:277-280). Failure → silent drop; note the
// timeout refresh below is NOT reached, so a CRC-failing flood
// cannot keep a session alive.
if (!VerifyCrc(packet))
{
CrcDropCount++;
return;
}
// 2. Cleartext-NAK early handling (:283-308): RequestRetransmit set
// AND EncryptedChecksum NOT set → serve retransmits (immediate raw
// sends), queue RejectRetransmit for uncached ids, and RETURN —
// before the timeout refresh, so NAKs never refresh ACE's 60 s
// timeout. Encrypted NAKs fall through and are effectively
// ignored (:283-284 requires the cleartext form).
if ((packet.Header.Flags & PacketHeaderFlags.RequestRetransmit) != 0
&& (packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) == 0)
{
List<uint>? uncached = null;
foreach (uint sequence in packet.Optional.RetransmitRequests)
{
if (!TryRetransmit(sequence))
(uncached ??= new List<uint>()).Add(sequence);
}
if (uncached is not null)
EnqueueRejectRetransmit(uncached); // :299-304 (sent on the next Update flush)
return; // :307
}
// 3. Disconnect headers (:312-321).
if ((packet.Header.Flags & PacketHeaderFlags.Disconnect) != 0)
{
Terminate(AceTerminationReason.PacketHeaderDisconnect);
return;
}
if ((packet.Header.Flags & PacketHeaderFlags.NetErrorDisconnect) != 0)
{
Terminate(AceTerminationReason.ClientSentNetworkErrorDisconnect);
return;
}
// 4. Timeout refresh (:329-331) — 60 s in-world horizon.
TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks;
// 5. Duplicate rejection (:342-347). Raw unsigned comparison — NOT
// wrap-safe, exactly like ACE (a wrapped client sequence would be
// mis-classified; modeled bug-for-bug). The ack-only exemption is
// an EQUALITY check on the whole flags field, never HasFlag, and
// only at seq == watermark exactly.
if (packet.Header.Sequence <= _lastReceivedPacketSequence
&& packet.Header.Sequence != 0
&& !(packet.Header.Flags == PacketHeaderFlags.AckSequence
&& packet.Header.Sequence == _lastReceivedPacketSequence))
{
DuplicateDropCount++;
return;
}
// 6. Out-of-order buffering (:351-363). NAK trigger fires only at
// desiredSeq + 2 ≤ arrivedSeq, arrival-driven, with a 1 s rate
// limit; a quiet link is never NAKed.
uint desiredSeq = _lastReceivedPacketSequence + 1;
if (packet.Header.Sequence > desiredSeq)
{
if (!_outOfOrderPackets.ContainsKey(packet.Header.Sequence))
_outOfOrderPackets.Add(packet.Header.Sequence, packet);
bool rateLimitOpen =
_lastNakTimestamp is null
|| _clock.GetTimestamp() - _lastNakTimestamp.Value > NakRateLimitTicks;
if (desiredSeq + 2 <= packet.Header.Sequence && rateLimitOpen)
DoRequestForRetransmission(packet.Header.Sequence);
return;
}
// 7. Final processing (:367-378).
HandleOrderedPacket(packet);
CheckOutOfOrderPackets();
CheckOutOfOrderFragments();
}
/// <summary>
/// Session.CheckState (Session.cs:93-105). Three drops, all of them
/// BEFORE <c>NetworkSession.ProcessPacket</c> and therefore before
/// <c>ClientPacket.VerifyCRC</c> — no keystream word is consumed, no
/// watermark moves, no CRC counter ticks. Note ACE's
/// <c>PacketHeader.HasFlag</c> is ANY-of, not all-of
/// (PacketHeader.cs:70), so the fourth line drops a packet carrying ANY
/// of the four control flags.
/// </summary>
private bool CheckState(PacketHeader header)
{
// :95-96
if ((header.Flags & PacketHeaderFlags.LoginRequest) != 0
&& State != AceSessionState.AuthLoginRequest)
{
return false;
}
// :98-99 (and the identical requirement on NetworkManager's port+1
// path, NetworkManager.cs:60-66).
if ((header.Flags & PacketHeaderFlags.ConnectResponse) != 0
&& State != AceSessionState.AuthConnectResponse)
{
return false;
}
// :101-102
const PacketHeaderFlags controlFlags =
PacketHeaderFlags.AckSequence
| PacketHeaderFlags.TimeSync
| PacketHeaderFlags.EchoRequest
| PacketHeaderFlags.Flow;
if ((header.Flags & controlFlags) != 0
&& State == AceSessionState.AuthLoginRequest)
{
return false;
}
return true;
}
/// <summary>ClientPacket.VerifyCRC (ClientPacket.cs:138-163) over the crypto model.</summary>
private bool VerifyCrc(ParsedPacket packet)
{
uint headerHash = packet.Header.CalculateHeaderHash32();
uint payloadHash = packet.Optional.CalculateHash32() + packet.FragmentHash;
if ((packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) != 0)
{
// ClientPacket.cs:140-147 — extract the key, Search, then Consume.
uint key = (packet.Header.Checksum - headerHash) ^ payloadHash;
if (Crypto.Search(key))
{
Crypto.ConsumeKey(key);
return true;
}
return false;
}
// ClientPacket.cs:149-157 — additive cleartext checksum.
return headerHash + payloadHash == packet.Header.Checksum;
}
/// <summary>
/// NetworkManager.cs:50-79 — ConnectResponse routing. The double only
/// supports the exact shape retail/acdream sends (flags ==
/// ConnectResponse alone, 8-byte cookie body). The
/// <c>State == AuthConnectResponse</c> half of NetworkManager's session
/// lookup (:64) is enforced by <see cref="CheckState"/> above.
/// </summary>
private void HandleConnectResponse(ParsedPacket packet)
{
if (packet.Header.Flags != PacketHeaderFlags.ConnectResponse)
return;
if (packet.Optional.RawBytes.Length < 8)
return;
ulong cookie = BinaryPrimitives.ReadUInt64LittleEndian(packet.Optional.RawBytes);
if (cookie != _cookie)
return; // NetworkManager.cs:60-66 — cookie mismatch: no session matches, ignored
State = AceSessionState.AuthConnected; // NetworkManager.cs:77
_sendResync = true; // NetworkManager.cs:78 — first TimeSync goes out immediately (:47-50)
TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks;
ConnectResponseAccepted?.Invoke();
}
/// <summary>NetworkSession.HandleOrderedPacket (:435-477).</summary>
private void HandleOrderedPacket(ParsedPacket packet)
{
// :440-443 + :650-661 — EchoRequest flags an EchoResponse onto the
// InvalidQueue bundle (and forces its checksum encrypted).
if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0)
FlagEcho(packet.Optional.EchoRequestClientTime);
// :447-448 — consume the cumulative-ack VALUE: prune the S2C cache
// strictly below it.
if ((packet.Header.Flags & PacketHeaderFlags.AckSequence) != 0)
AcknowledgeSequence(packet.Optional.AckSequence);
// :450-457 — inbound TimeSync is read and ignored.
// :463-468 — LoginRequest short-circuits to the auth handler and
// RETURNS: no fragment processing and, crucially, no watermark
// advance for LoginRequest packets.
if ((packet.Header.Flags & PacketHeaderFlags.LoginRequest) != 0)
{
LoginRequestReceived?.Invoke();
return;
}
// :471-472 — fragments.
foreach (MessageFragment fragment in packet.Fragments)
ProcessFragment(fragment);
// :474-476 — THE WATERMARK-HOLE RULE, pinned: the watermark advances
// for every packet whose Sequence != 0 && Flags != AckSequence — an
// EXACT equality check on the whole flags field. Any cleartext
// non-ack control packet reusing a live sequence number advances the
// watermark and permanently skips the real packet at that sequence.
if (packet.Header.Sequence != 0
&& packet.Header.Flags != PacketHeaderFlags.AckSequence)
{
_lastReceivedPacketSequence = packet.Header.Sequence;
}
}
/// <summary>NetworkSession.FlagEcho (:650-661).</summary>
private void FlagEcho(float clientTime)
{
PendingBundle bundle = _bundles[(int)GameMessageGroup.InvalidQueue];
bundle.ClientTime = clientTime;
bundle.EncryptedChecksum = true;
}
/// <summary>NetworkSession.ProcessFragment (:483-544).</summary>
private void ProcessFragment(MessageFragment fragment)
{
byte[]? message = null;
if (fragment.Header.Count != 1)
{
// ACE's two literal branches, kept separate because only ONE of
// them checks Complete:
if (_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer))
{
// :495-507 — existing buffer: add, then check Complete.
buffer.AddFragment(fragment.Header.Index, fragment.Payload);
if (buffer.Complete)
{
// :504-506 — TryGetMessage may return null (assembled
// stream under 4 bytes, MessageBuffer.cs:49-50) but the
// buffer is removed EITHER WAY.
message = buffer.TryGetMessage();
_partialFragments.Remove(fragment.Header.Sequence);
}
}
else
{
// :509-518 — new buffer: construct + AddFragment + TryAdd,
// WITHOUT checking Complete. A fragment whose Count can
// never be reached from here (Count == 0: AddFragment
// refuses to add to an already-"Complete" buffer) parks a
// dead buffer in partialFragments forever — ACE
// bug-for-bug.
var newBuffer = new PartialC2SMessage(fragment.Header.Count);
newBuffer.AddFragment(fragment.Header.Index, fragment.Payload);
_partialFragments.TryAdd(fragment.Header.Sequence, newBuffer);
}
}
else if (fragment.Payload.Length >= 4)
{
// :520-527 — unsplit; ClientMessage needs ≥ 4 bytes.
message = fragment.Payload;
}
if (message is null)
return;
// :532-543 — THE C2S FRAGMENT GATE, pinned: a completed message
// dispatches only when its fragment sequence is exactly
// lastReceivedFragmentSequence + 1; anything else parks in
// outOfOrderFragments (including OLD fragment sequences, which park
// forever — ACE bug-for-bug). A message dropped for being under
// 4 bytes never reaches here, so it never advances the gate: every
// later message stalls behind the hole it leaves.
if (fragment.Header.Sequence == _lastReceivedFragmentSequence + 1)
HandleFragment(message);
else
_outOfOrderFragments.TryAdd(fragment.Header.Sequence, message);
}
/// <summary>NetworkSession.HandleFragment (:550-554).</summary>
private void HandleFragment(byte[] message)
{
_dispatchedMessages.Add(message);
MessageDispatched?.Invoke(message);
_lastReceivedFragmentSequence++;
}
/// <summary>NetworkSession.CheckOutOfOrderPackets (:559-566).</summary>
private void CheckOutOfOrderPackets()
{
while (_outOfOrderPackets.Remove(_lastReceivedPacketSequence + 1, out ParsedPacket? packet))
HandleOrderedPacket(packet);
}
/// <summary>NetworkSession.CheckOutOfOrderFragments (:571-578).</summary>
private void CheckOutOfOrderFragments()
{
while (_outOfOrderFragments.Remove(_lastReceivedFragmentSequence + 1, out byte[]? message))
HandleFragment(message);
}
/// <summary>NetworkSession.AcknowledgeSequence (:663-673) — prune strictly-older
/// cached S2C packets. Raw uint compare (`x &lt; sequence`), NOT wrap-safe:
/// modeled exactly as ACE does it.</summary>
private void AcknowledgeSequence(uint sequence)
{
List<uint>? removal = null;
foreach (uint key in _cachedPackets.Keys)
{
if (key < sequence)
(removal ??= new List<uint>()).Add(key);
}
if (removal is null)
return;
foreach (uint key in removal)
_cachedPackets.Remove(key);
}
/// <summary>NetworkSession.DoRequestForRetransmission (:387-426).</summary>
private void DoRequestForRetransmission(uint rcvdSeq)
{
uint desiredSeq = _lastReceivedPacketSequence + 1; // :389
var needSeq = new List<uint> { desiredSeq }; // :390-391
uint bottom = desiredSeq + 1; // :392
// :393-397 — gap beyond the 256-key crypto search window is fatal.
// Note this check lives INSIDE the rate-limited call, exactly like
// ACE: a huge gap arriving while the 1 s limiter is closed does NOT
// terminate until the next NAK-eligible arrival.
if (rcvdSeq < bottom || rcvdSeq - bottom > AceCryptoModel.MaximumEffortLevel)
{
Terminate(AceTerminationReason.AbnormalSequenceReceived);
return;
}
uint seqIdCount = 1; // :398-410 — cap at 115 ids, skipping buffered arrivals
for (uint a = bottom; a < rcvdSeq; a++)
{
if (_outOfOrderPackets.ContainsKey(a))
continue;
needSeq.Add(a);
seqIdCount++;
if (seqIdCount >= MaxNumNakSeqIds)
break;
}
// :412-420 — u32 count + ids, flags RequestRetransmit, CLEARTEXT
// (ServerPacket default — no EncryptedChecksum), queued for the next
// FlushPackets pass.
byte[] body = new byte[4 + needSeq.Count * 4];
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)needSeq.Count);
for (int i = 0; i < needSeq.Count; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
body.AsSpan(4 + i * 4),
needSeq[i]);
}
_flushQueue.Enqueue(new OutboundDraft(
PacketHeaderFlags.RequestRetransmit,
body,
OptionalLength: body.Length));
_lastNakTimestamp = _clock.GetTimestamp(); // :422
}
/// <summary>NetworkSession.Retransmit (:675-708) — serve a NAKed id from the cache.</summary>
private bool TryRetransmit(uint sequence)
{
if (!_cachedPackets.TryGetValue(sequence, out CachedS2CPacket? cached))
return false; // :707 — caller collects the id for RejectRetransmit
// :681-682 — OR the Retransmission flag INTO THE CACHE ENTRY (it
// sticks for any later retransmit of the same packet).
cached.Flags |= PacketHeaderFlags.Retransmission;
// :684 SendPacketRaw → ServerPacket.CreateReadyToSendPacket
// (ServerPacket.cs:46-72): the header hash is recomputed with the new
// flags, the checksum reuses the ORIGINAL IssacXor — NO new keystream
// word is drawn — and Header.Time keeps its original flush value.
// The retransmit bypasses FlushPackets: it is emitted immediately,
// before any queued RejectRetransmit.
Emit(cached.Sequence, cached.Flags, cached.Time, cached.Body, cached.OptionalLength, cached.IsaacXor);
RetransmitsServed++;
return true;
}
/// <summary>NetworkSession.cs:299-304 + PacketRejectRetransmit.cs:7-17 —
/// u32 count + uncached ids, cleartext, queued (flows through FlushPackets,
/// so like ACE it consumes a sequence number and can even be cached).</summary>
private void EnqueueRejectRetransmit(List<uint> uncached)
{
byte[] body = new byte[4 + uncached.Count * 4];
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)uncached.Count);
for (int i = 0; i < uncached.Count; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
body.AsSpan(4 + i * 4),
uncached[i]);
}
_flushQueue.Enqueue(new OutboundDraft(
PacketHeaderFlags.RejectRetransmit,
body,
OptionalLength: body.Length));
}
// =====================================================================
// Send side — Session.TickOutbound (Session.cs:119-176) →
// NetworkSession.Update (:182-249) + SendBundle (:808-919) +
// FlushPackets (:710-735) + SendPacket (:737-752), driven by the virtual
// clock.
// =====================================================================
/// <summary>
/// One server pump: the termination window, the timeout check, then the
/// network update (cache prune, bundles, flush). ACE runs this from the
/// world tick; the double runs it whenever the harness pumps.
/// </summary>
public void Update()
{
if (IsReleased)
return; // NetworkSession.cs:184-185
// Session.cs:124-134 — the two-phase termination window. Everything
// keeps running for ~2 s (SessionTerminationDetails.cs:12) so boot
// messages and queued packets can still reach the client.
if (TerminationPhase is not null)
{
if (TerminationPhase == AceTerminationPhase.Initialized)
{
RunNetworkUpdate(); // :129 — "boot messages may need sending"
if (_clock.GetTimestamp() > _terminationEndTimestamp)
TerminationPhase = AceTerminationPhase.SessionWorkCompleted; // :130-131
}
// NetworkManager.cs:366-369 drains completed sessions through
// Session.DropSession (:300-334), whose last act is
// NetworkSession.ReleaseResources (:333 → :958-974). The double
// folds that drain into the same pump.
if (TerminationPhase == AceTerminationPhase.SessionWorkCompleted)
Release();
return; // :133
}
// Session.cs:140-144 — `DateTime.UtcNow.Ticks >= Network.TimeoutTick`.
// The boundary is inclusive: at exactly the deadline the session dies.
// Every ACE transport death is silence — no disconnect packet is sent.
if (_clock.GetTimestamp() >= TimeoutDeadlineTimestamp)
{
Terminate(AceTerminationReason.NetworkTimeout);
return;
}
RunNetworkUpdate(); // :147
}
/// <summary>NetworkSession.Update (:182-249).</summary>
private void RunNetworkUpdate()
{
// :187-188 — prune the S2C cache every 5 s.
if (_lastPruneTimestamp is null
|| _clock.GetTimestamp() - _lastPruneTimestamp.Value > CachePruneIntervalTicks)
{
PruneCachedPackets();
}
if (State == AceSessionState.AuthConnected)
{
// :190-246 — walk every bundle in ascending group order, arm the
// InvalidQueue control flags, swap out and send whatever needs
// sending.
for (int i = 0; i < QueueMax; i++)
{
var group = (GameMessageGroup)i;
PendingBundle bundle = _bundles[i];
if (group == GameMessageGroup.InvalidQueue)
{
// :203-209 — TimeSync forces an encrypted checksum.
if (_sendResync
&& !bundle.TimeSync
&& (_nextResyncTimestamp is null
|| _clock.GetTimestamp() > _nextResyncTimestamp.Value))
{
bundle.TimeSync = true;
bundle.EncryptedChecksum = true;
_nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks;
}
// :211-216 — sendAck is always true (:54); a pure ack stays
// CLEARTEXT.
if (!bundle.SendAck && _clock.GetTimestamp() > _nextAckTimestamp)
{
bundle.SendAck = true;
_nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks;
}
}
if (!bundle.NeedsSending)
continue;
_bundles[i] = new PendingBundle(); // :223 / :233 — swap
SendBundle(bundle, group); // :243
}
}
// FlushPackets (:710-735) — drains receive-time NAK/Reject enqueues
// first (FIFO), then this pump's bundles.
while (_flushQueue.TryDequeue(out OutboundDraft draft))
FlushOne(draft);
}
/// <summary>
/// Server-side game-message send — NetworkSession.EnqueueSend (:117-134):
/// the message joins its group's bundle and forces that bundle's checksum
/// encrypted (:129). It leaves on the next <see cref="Update"/>, coalesced
/// with whatever else is in the same bundle.
/// </summary>
public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group)
{
PendingBundle bundle = _bundles[(int)group];
bundle.EncryptedChecksum = true;
bundle.Enqueue(gameMessageBody);
}
/// <summary>
/// AuthenticationHandler → PacketOutboundConnectRequest
/// (AuthenticationHandler.cs:118-127): 32-byte cleartext section
/// (serverTime, cookie, clientId, serverSeed, clientSeed, padding), queued
/// through the normal packet flush — its sequence is 0, the first
/// NextValue of the unprimed UIntSequence. The same callback moves the
/// session to AuthConnectResponse (AuthenticationHandler.cs:232), which is
/// what closes the LoginRequest half of <see cref="CheckState"/>.
/// </summary>
public void SendConnectRequest()
{
byte[] body = new byte[32];
BinaryPrimitives.WriteInt64LittleEndian(
body,
BitConverter.DoubleToInt64Bits(_clock.Seconds));
BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), _cookie);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), _clientId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), _serverSeed);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), _clientSeed);
// bytes 28..31: trailing padding uint, zero.
_flushQueue.Enqueue(new OutboundDraft(
PacketHeaderFlags.ConnectRequest,
body,
OptionalLength: body.Length));
State = AceSessionState.AuthConnectResponse; // AuthenticationHandler.cs:232
}
/// <summary>
/// NetworkSession.SendBundle (:808-919) — turn one bundle into one OR MORE
/// packets: as many same-bundle fragments as fit in the 464-byte body
/// budget travel together (one sequence, one keystream word), and a
/// message whose remaining data fills a packet is split across packets
/// with <c>Count &gt; 1</c> fragments.
///
/// <para>
/// Fragment sequences are assigned HERE (:821), from
/// SessionConnectionData.FragmentSequence (starting at 0), in bundle
/// order; the fragment Id is the constant 0x80000000 (MessageFragment.cs:94).
/// </para>
/// </summary>
private void SendBundle(PendingBundle bundle, GameMessageGroup group)
{
bool writeOptionalHeaders = true;
// :817-823 — pull every message out and wrap it in a MessageFragment.
var fragments = new List<OutboundMessage>();
while (bundle.HasMoreMessages)
fragments.Add(new OutboundMessage(bundle.Dequeue(), _s2cFragmentSequence++, group));
// :828 — loop while we have fragments (or still owe optional headers).
while (fragments.Count > 0 || writeOptionalHeaders)
{
var flags = PacketHeaderFlags.None;
var packetFragments = new List<MessageFragment>();
byte[] optionalBytes = Array.Empty<byte>();
if (fragments.Count > 0)
flags |= PacketHeaderFlags.BlobFragments; // :833-834
if (bundle.EncryptedChecksum)
flags |= PacketHeaderFlags.EncryptedChecksum; // :836-837
int availableSpace = MaxPacketSize; // :839
OutboundMessage? firstMessage = fragments.Count > 0 ? fragments[0] : null; // :842
if (firstMessage is not null)
{
if (firstMessage.DataRemaining >= availableSpace)
{
// :846-854 — a large message fills the whole packet alone.
MessageFragment spf = firstMessage.GetNextFragment();
packetFragments.Add(spf);
availableSpace -= spf.WireSize;
if (firstMessage.DataRemaining <= 0)
fragments.Remove(firstMessage);
}
else
{
// :856-903 — optional headers first, then pack in as many
// small messages (and large-message tails) as fit.
if (writeOptionalHeaders)
{
writeOptionalHeaders = false;
optionalBytes = WriteOptionalHeaders(bundle, ref flags);
availableSpace -= optionalBytes.Length;
}
var removeList = new List<OutboundMessage>();
foreach (OutboundMessage fragment in fragments)
{
bool fragmentSkipped = false;
if (!fragment.TailSent && availableSpace >= fragment.TailSize)
{
// :874-880 — the tail of an already-split message.
MessageFragment spf = fragment.GetTailFragment();
packetFragments.Add(spf);
availableSpace -= spf.WireSize;
}
else if (availableSpace >= fragment.NextSize)
{
// :882-888 — a whole small message.
MessageFragment spf = fragment.GetNextFragment();
packetFragments.Add(spf);
availableSpace -= spf.WireSize;
}
else
{
fragmentSkipped = true;
}
if (fragment.DataRemaining <= 0)
removeList.Add(fragment); // :892-894
// :896-898 — UIQueue must stay strictly ordered.
if (fragmentSkipped && group == GameMessageGroup.UIQueue)
break;
}
fragments.RemoveAll(removeList.Contains); // :902
}
}
else if (writeOptionalHeaders)
{
// :906-916 — no messages: a control-only packet.
writeOptionalHeaders = false;
optionalBytes = WriteOptionalHeaders(bundle, ref flags);
}
_flushQueue.Enqueue(BuildDraft(flags, optionalBytes, packetFragments)); // :917
}
}
/// <summary>
/// NetworkSession.WriteOptionalHeaders (:921-948) — ack value, then
/// TimeSync, then EchoResponse, in that order. The EncryptedChecksum
/// forcing for TimeSync/EchoResponse lives on the BUNDLE (:207, :659), not
/// here, so a pure ack stays cleartext.
/// </summary>
private byte[] WriteOptionalHeaders(PendingBundle bundle, ref PacketHeaderFlags flags)
{
var writer = new PacketWriter(24);
if (bundle.SendAck) // :925-931
{
flags |= PacketHeaderFlags.AckSequence;
writer.WriteUInt32(_lastReceivedPacketSequence);
}
if (bundle.TimeSync) // :933-939
{
flags |= PacketHeaderFlags.TimeSync;
Span<byte> value = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(
value,
BitConverter.DoubleToInt64Bits(_clock.Seconds));
writer.WriteBytes(value);
}
if (bundle.ClientTime != -1f) // :941-948
{
flags |= PacketHeaderFlags.EchoResponse;
writer.WriteFloat(bundle.ClientTime);
writer.WriteFloat((float)_clock.Seconds - bundle.ClientTime);
}
return writer.ToArray();
}
/// <summary>Concatenate the optional section and the packet's fragments into
/// one flush draft (ServerPacket.Data + ServerPacket.Fragments).</summary>
private static OutboundDraft BuildDraft(
PacketHeaderFlags flags,
byte[] optionalBytes,
List<MessageFragment> fragments)
{
int total = optionalBytes.Length;
foreach (MessageFragment fragment in fragments)
total += fragment.WireSize;
byte[] body = new byte[total];
optionalBytes.CopyTo(body.AsSpan());
int offset = optionalBytes.Length;
foreach (MessageFragment fragment in fragments)
{
fragment.Header.Pack(body.AsSpan(offset));
fragment.Payload.CopyTo(body.AsSpan(offset + MessageFragmentHeader.Size));
offset += fragment.WireSize;
}
return new OutboundDraft(flags, body, optionalBytes.Length);
}
/// <summary>FlushPackets, per packet (:710-735) + SendPacket (:737-752).</summary>
private void FlushOne(OutboundDraft draft)
{
bool encrypted = (draft.Flags & PacketHeaderFlags.EncryptedChecksum) != 0;
// :716-717 — the first encrypted flush re-primes the sequence to
// CurrentValue = 1, so the first encrypted S2C packet is sequence 2.
if (encrypted && _packetSequence == 0)
_packetSequence = 1;
bool isNak = (draft.Flags & PacketHeaderFlags.RequestRetransmit) != 0; // :719
// :722-725 — ack-only (EXACT flags) and NAK packets reuse the current
// sequence without incrementing; everything else takes NextValue.
uint sequence = draft.Flags == PacketHeaderFlags.AckSequence || isNak
? _packetSequence
: NextPacketSequence();
// :728 — Header.Time = (ushort)PortalYearTicks (whole seconds).
ushort time = (ushort)(long)_clock.Seconds;
// SendPacket (:743-748) — one S2C keystream word per encrypted
// packet; cleartext packets use xor 0 (ServerPacket.cs:70 makes the
// checksum additive in that case).
uint isaacXor = encrypted ? _s2cKeystream.Next() : 0u;
// :730-731 — cache sequenced packets ≥ 2 that are not NAKs. TryAdd
// semantics: an ack reusing a live sequence does not overwrite.
if (sequence >= 2u && !isNak)
{
_cachedPackets.TryAdd(sequence, new CachedS2CPacket
{
Sequence = sequence,
Flags = draft.Flags,
Time = time,
Body = draft.Body,
OptionalLength = draft.OptionalLength,
IsaacXor = isaacXor,
});
}
Emit(sequence, draft.Flags, time, draft.Body, draft.OptionalLength, isaacXor);
}
/// <summary>UIntSequence.NextValue (UIntSequence.cs:30-41): wrap max → 0.</summary>
private uint NextPacketSequence()
{
_packetSequence = _packetSequence == uint.MaxValue ? 0u : _packetSequence + 1u;
return _packetSequence;
}
/// <summary>ServerPacket.CreateReadyToSendPacket (ServerPacket.cs:46-72).</summary>
private void Emit(
uint sequence,
PacketHeaderFlags flags,
ushort time,
byte[] body,
int optionalLength,
uint isaacXor)
{
var header = new PacketHeader
{
Sequence = sequence,
Flags = flags,
Id = _serverId, // :726
Iteration = 1, // :727
Time = time,
DataSize = checked((ushort)body.Length),
};
uint payloadHash = ComputePayloadHash(body, flags, optionalLength);
uint headerHash = header.CalculateHeaderHash32();
header.Checksum = headerHash + (payloadHash ^ isaacXor); // ServerPacket.cs:70
byte[] datagram = new byte[PacketHeader.Size + body.Length];
header.Pack(datagram);
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
_sentDatagrams.Add(datagram);
_pendingOutbound.Enqueue(datagram);
}
/// <summary>ServerPacket.cs:48-62 — Hash32(data section) + Σ fragment hashes.</summary>
private static uint ComputePayloadHash(
ReadOnlySpan<byte> body,
PacketHeaderFlags flags,
int optionalLength)
{
uint hash = Hash32.Calculate(body.Slice(0, optionalLength));
if ((flags & PacketHeaderFlags.BlobFragments) == 0)
return hash;
ReadOnlySpan<byte> remaining = body.Slice(optionalLength);
while (!remaining.IsEmpty)
{
if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed))
throw new InvalidOperationException("the model built a malformed fragment");
hash += PacketCodec.CalculateFragmentHash32(fragment);
remaining = remaining.Slice(consumed);
}
return hash;
}
/// <summary>NetworkSession.PruneCachedPackets (:251-262) — 120 s retention
/// with ACE's ushort-wrap guard expression, verbatim. Strictly greater
/// than 120: an entry exactly 120 s old survives.</summary>
private void PruneCachedPackets()
{
_lastPruneTimestamp = _clock.GetTimestamp(); // :253
ushort currentTime = (ushort)(long)_clock.Seconds; // :255
List<uint>? removal = null;
foreach (CachedS2CPacket packet in _cachedPackets.Values)
{
// :258 — wrap guard: `(currentTime >= x.Time ? currentTime : currentTime + ushort.MaxValue) - x.Time > 120`
if ((currentTime >= packet.Time ? currentTime : currentTime + ushort.MaxValue) - packet.Time
> CachedPacketRetentionSeconds)
{
(removal ??= new List<uint>()).Add(packet.Sequence);
}
}
if (removal is null)
return;
foreach (uint sequence in removal)
_cachedPackets.Remove(sequence);
}
/// <summary>
/// Session.Terminate (Session.cs:281-298) — arms PendingTermination with a
/// 2 s window (SessionTerminationDetails.cs:11-12). It does NOT stop the
/// session: inbound keeps processing and outbound keeps flushing until
/// <see cref="Release"/>. A second Terminate overwrites the details, exactly
/// like ACE (:293).
/// </summary>
private void Terminate(AceTerminationReason reason)
{
TerminationReason = reason;
TerminationPhase = AceTerminationPhase.Initialized;
_terminationEndTimestamp = _clock.GetTimestamp() + TerminationWindowTicks;
}
/// <summary>NetworkSession.ReleaseResources (:958-974), reached through
/// Session.DropSession (:300-334).</summary>
private void Release()
{
IsReleased = true;
_outOfOrderPackets.Clear();
_partialFragments.Clear();
_outOfOrderFragments.Clear();
_cachedPackets.Clear();
_flushQueue.Clear();
for (int i = 0; i < _bundles.Length; i++)
_bundles[i] = new PendingBundle(); // :962-963 (ACE nulls them)
}
// =====================================================================
// Parsing — ClientPacket.Unpack (ClientPacket.cs:22-76) equivalent over
// acdream's owned wire types. Malformed datagrams are dropped silently.
// =====================================================================
private static bool TryParse(ReadOnlySpan<byte> datagram, out ParsedPacket packet)
{
packet = null!;
if (datagram.Length < PacketHeader.Size)
return false; // ClientPacket.cs:26-27
PacketHeader header = PacketHeader.Unpack(datagram);
if (header.DataSize > datagram.Length - PacketHeader.Size)
return false; // ClientPacket.cs:31-32
ReadOnlySpan<byte> body = datagram.Slice(PacketHeader.Size, header.DataSize);
var optional = new PacketHeaderOptional();
int optionalConsumed = optional.Parse(body, header.Flags);
if (optionalConsumed < 0)
return false; // ClientPacket.cs:38-39 (HeaderOptional.IsValid)
var fragments = new List<MessageFragment>();
uint fragmentHash = 0;
if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
{
// ClientPacket.ReadFragments (:54-76) + fragmentChecksum (:84-101).
ReadOnlySpan<byte> remaining = body.Slice(optionalConsumed);
while (!remaining.IsEmpty)
{
if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed))
return false;
fragments.Add(fragment);
fragmentHash += PacketCodec.CalculateFragmentHash32(fragment);
remaining = remaining.Slice(consumed);
}
}
packet = new ParsedPacket(header, optional, fragments, fragmentHash);
return true;
}
/// <summary>
/// ClientPacketFragment.Unpack (ClientPacketFragment.cs:10-23) — ACE's
/// COMPLETE inbound fragment validation: a 16-byte header, then
/// <c>Size 16 &gt;= 0</c> (:14-15) and <c>Size &lt;= 464</c> (:17-18).
/// Deliberately looser than acdream's production
/// <c>MessageFragment.TryParseLayout</c>, which additionally rejects
/// <c>Count == 0</c> and <c>Index &gt;= Count</c>: the double's C2S parse
/// path exists to characterize ACE, so it must accept everything ACE
/// accepts. ACE's <c>BinaryReader.ReadBytes</c> also tolerates a short
/// read at the end of the body (:20), producing a truncated payload
/// instead of a parse failure — modeled here by clamping.
/// </summary>
private static bool TryParseClientFragment(
ReadOnlySpan<byte> source,
out MessageFragment fragment,
out int consumed)
{
fragment = default;
consumed = 0;
// A header that cannot be read throws inside ACE's reader and is
// caught as "corrupt packet" (ClientPacket.cs:67-71).
if (source.Length < MessageFragmentHeader.Size)
return false;
MessageFragmentHeader header = MessageFragmentHeader.Unpack(source);
if (header.TotalSize < MessageFragmentHeader.Size)
return false; // ClientPacketFragment.cs:14-15
if (header.TotalSize > MessageFragmentHeader.MaxFragmentSize)
return false; // ClientPacketFragment.cs:17-18
int payloadLength = Math.Min(
header.TotalSize - MessageFragmentHeader.Size,
source.Length - MessageFragmentHeader.Size);
fragment = new MessageFragment(
header,
source.Slice(MessageFragmentHeader.Size, payloadLength).ToArray());
consumed = MessageFragmentHeader.Size + payloadLength;
return true;
}
/// <summary>
/// A parsed, CRC-verifiable C2S packet. Buffered out-of-order packets are
/// stored in THIS form — ACE never re-verifies a buffered packet's CRC
/// (the key was consumed on first arrival).
/// </summary>
private sealed record ParsedPacket(
PacketHeader Header,
PacketHeaderOptional Optional,
List<MessageFragment> Fragments,
uint FragmentHash);
private readonly record struct OutboundDraft(
PacketHeaderFlags Flags,
byte[] Body,
int OptionalLength);
/// <summary>The cached ServerPacket surrogate — see FlushOne/TryRetransmit.</summary>
private sealed class CachedS2CPacket
{
public uint Sequence;
public PacketHeaderFlags Flags;
public ushort Time;
public byte[] Body = Array.Empty<byte>();
public int OptionalLength;
public uint IsaacXor;
}
/// <summary>ACE NetworkBundle surrogate (NetworkBundle.cs:6-63) — one per
/// GameMessageGroup, swapped out whole when it needs sending.</summary>
private sealed class PendingBundle
{
private readonly Queue<byte[]> _messages = new();
private bool _propChanged;
/// <summary>NetworkBundle.cs:11.</summary>
public bool NeedsSending => _propChanged || _messages.Count > 0;
/// <summary>NetworkBundle.cs:13.</summary>
public bool HasMoreMessages => _messages.Count > 0;
private float _clientTime = -1f;
/// <summary>NetworkBundle.cs:18-27 — -1f means "no echo pending".</summary>
public float ClientTime
{
get => _clientTime;
set { _clientTime = value; _propChanged = true; }
}
private bool _timeSync;
/// <summary>NetworkBundle.cs:29-38.</summary>
public bool TimeSync
{
get => _timeSync;
set { _timeSync = value; _propChanged = true; }
}
private bool _sendAck;
/// <summary>NetworkBundle.cs:40-49.</summary>
public bool SendAck
{
get => _sendAck;
set { _sendAck = value; _propChanged = true; }
}
/// <summary>NetworkBundle.cs:51.</summary>
public bool EncryptedChecksum { get; set; }
public void Enqueue(byte[] message) => _messages.Enqueue(message);
public byte[] Dequeue() => _messages.Dequeue();
}
/// <summary>
/// ACE's server-side MessageFragment (MessageFragment.cs:10-103): one
/// queued GameMessage plus the split bookkeeping SendBundle drives.
/// </summary>
private sealed class OutboundMessage
{
private readonly byte[] _data;
private readonly GameMessageGroup _group;
private ushort _index;
public uint Sequence { get; }
public ushort Count { get; }
public int DataRemaining { get; private set; }
public bool TailSent { get; private set; }
public int DataLength => _data.Length;
/// <summary>MessageFragment.cs:27-36.</summary>
public int NextSize =>
MessageFragmentHeader.Size
+ Math.Min(DataRemaining, MessageFragmentHeader.MaxFragmentDataSize);
/// <summary>MessageFragment.cs:38.</summary>
public int TailSize =>
MessageFragmentHeader.Size
+ (DataLength % MessageFragmentHeader.MaxFragmentDataSize);
public OutboundMessage(byte[] data, uint sequence, GameMessageGroup group)
{
_data = data;
_group = group;
Sequence = sequence;
DataRemaining = data.Length;
// :47 — ceil(length / 448).
Count = (ushort)Math.Ceiling(
(double)data.Length / MessageFragmentHeader.MaxFragmentDataSize);
_index = 0;
if (Count == 1)
TailSent = true; // :49-50
}
/// <summary>MessageFragment.cs:54-59.</summary>
public MessageFragment GetTailFragment()
{
var index = (ushort)(Count - 1);
TailSent = true;
return CreateFragment(index);
}
/// <summary>MessageFragment.cs:61-64.</summary>
public MessageFragment GetNextFragment() => CreateFragment(_index++);
/// <summary>MessageFragment.cs:66-102.</summary>
private MessageFragment CreateFragment(ushort index)
{
if (index >= Count)
throw new ArgumentOutOfRangeException(nameof(index), index, "index beyond computed count");
int position = index * MessageFragmentHeader.MaxFragmentDataSize;
int dataToSend = Math.Min(
DataLength - position,
MessageFragmentHeader.MaxFragmentDataSize);
if (DataRemaining < dataToSend)
throw new InvalidOperationException("more data to send than data remaining");
byte[] payload = _data.AsSpan(position, dataToSend).ToArray();
DataRemaining -= dataToSend;
return new MessageFragment(
new MessageFragmentHeader
{
Sequence = Sequence,
Id = GameMessageFragment.OutboundFragmentId, // :94 — 0x80000000
Count = Count,
TotalSize = (ushort)(MessageFragmentHeader.Size + dataToSend),
Index = index,
Queue = (ushort)_group,
},
payload);
}
}
/// <summary>
/// ACE MessageBuffer surrogate (MessageBuffer.cs:7-54). Deliberately a
/// LIST keyed on nothing, exactly like ACE: <c>TotalFragments</c> is taken
/// from the FIRST fragment seen and completion is a COUNT match, so a
/// later fragment claiming a bigger Count/Index neither resizes the buffer
/// nor throws.
/// </summary>
private sealed class PartialC2SMessage
{
private readonly List<(ushort Index, byte[] Payload)> _fragments = new();
private readonly int _totalFragments;
public PartialC2SMessage(int totalFragments) => _totalFragments = totalFragments;
/// <summary>MessageBuffer.cs:14.</summary>
public bool Complete => _fragments.Count == _totalFragments;
/// <summary>MessageBuffer.cs:22-31 — ignored once complete, and one
/// fragment per Index.</summary>
public void AddFragment(ushort index, byte[] payload)
{
if (Complete)
return;
foreach ((ushort existing, _) in _fragments)
{
if (existing == index)
return;
}
_fragments.Add((index, payload));
}
/// <summary>
/// MessageBuffer.TryGetMessage (:36-53) — sort by Index, concatenate,
/// and return NULL when the assembled stream is under the 4-byte
/// ClientMessage minimum (:49-50). A null here is a dropped message
/// that never advances the fragment gate.
/// </summary>
public byte[]? TryGetMessage()
{
_fragments.Sort((x, y) => x.Index - y.Index); // :38
int total = 0;
foreach ((_, byte[] payload) in _fragments)
total += payload.Length;
if (total < 4)
return null; // :49-50
byte[] message = new byte[total];
int offset = 0;
foreach ((_, byte[] payload) in _fragments)
{
payload.CopyTo(message.AsSpan(offset));
offset += payload.Length;
}
return message;
}
}
}