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;
///
/// Termination causes the double can hit, mirroring ACE's
/// SessionTerminationReason names for the modeled paths.
///
internal enum AceTerminationReason
{
None,
/// NetworkSession.cs:312-315 — client sent a Disconnect header.
PacketHeaderDisconnect,
/// NetworkSession.cs:318-321 — client sent NetErrorDisconnect.
ClientSentNetworkErrorDisconnect,
/// NetworkSession.cs:393-397 — sequence gap beyond the crypto search window.
AbnormalSequenceReceived,
/// TimeoutTick (NetworkSession.cs:88, Session.cs:140-144) expired — every ACE transport death is silence.
NetworkTimeout,
}
///
/// ACE's SessionState (Network/Enum/SessionState.cs), reduced to the
/// three states a transport-level conversation can reach. The gate that reads
/// it is Session.CheckState (Session.cs:93-105).
///
///
/// Not modeled: WorldConnected (set by CharacterHandler.cs:260 — it
/// gates gameplay handlers, never the transport pipeline) and
/// TerminationStarted (Session.cs:128; only reachable with
/// PendingTermination already set, and the only code reading it,
/// Session.cs:136-137, is unreachable while it is).
///
///
internal enum AceSessionState
{
/// Pre-login. CheckState drops Ack/TimeSync/Echo/Flow here.
AuthLoginRequest,
/// ConnectRequest sent, waiting for the ConnectResponse (AuthenticationHandler.cs:232).
AuthConnectResponse,
/// Handshake complete (NetworkManager.cs:77).
AuthConnected,
}
/// ACE's SessionTerminationPhase (Network/Enum/SessionTerminationPhase.cs).
/// WorldManagerWorkCompleted is a post-drop bookkeeping marker
/// (NetworkManager.cs:369) with no transport-visible effect and is not
/// modeled.
internal enum AceTerminationPhase
{
/// Session.cs:126-131 — the ~2 s window in which inbound and outbound still run.
Initialized,
/// Session.cs:130-131 — the window elapsed; WorldManager may now DropSession.
SessionWorkCompleted,
}
///
/// Transport-free model of ACE's per-connection Session +
/// NetworkSession 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
/// references/ACE/Source/ACE.Server/Network/NetworkSession.cs (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.
///
///
/// Time comes exclusively from an injected — no
/// wall clock anywhere. The model is single-threaded by design; callers
/// (see ) serialize access.
///
///
///
/// Not an independent wire oracle. C2S CRC verification reuses
/// acdream's own 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 PacketHeaderOptional.Unpack are known and
/// deliberate:
///
/// - 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.
/// - 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).
///
/// The independent oracle for optional-header wire layout stays the live-ACE
/// connected gate, not this double.
///
///
///
/// Intentional simplifications, none affecting the pinned rules:
///
/// - 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 (ACE checks
/// TimeoutTick from Session.TickOutbound, Session.cs:140-144).
/// - 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.
/// - 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.
/// - VerifyEcho's speed-hack detector (NetworkSession.cs:593-647)
/// is not modeled: it can log off a player but never terminates the
/// transport session.
/// - 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.
///
///
///
internal sealed class AceSessionModel
{
// ---- ACE constants, cited ----
/// NetworkSession.cs:381 — max NAK ids per RequestRetransmit.
private const int MaxNumNakSeqIds = 115;
/// ServerPacket.cs:11 — the S2C body budget after the 20-byte header.
private const int MaxPacketSize = 464;
/// GameMessageGroup.cs:18 — the bundle array length.
private const int QueueMax = 0x0C;
/// NetworkSession.cs:359 — `new TimeSpan(0, 0, 1)` NAK rate limit.
private static readonly long NakRateLimitTicks = TimeSpan.FromSeconds(1).Ticks;
/// NetworkSession.cs:32 — timeBetweenAck = 2000 ms.
private static readonly long AckIntervalTicks = TimeSpan.FromSeconds(2).Ticks;
/// NetworkSession.cs:31 — timeBetweenTimeSync = 20000 ms.
private static readonly long TimeSyncIntervalTicks = TimeSpan.FromSeconds(20).Ticks;
/// NetworkManager.DefaultSessionTimeout (60 s), applied at NetworkSession.cs:329-331.
private static readonly long SessionTimeoutTicks = TimeSpan.FromSeconds(60).Ticks;
/// NetworkSession.cs:67 — cachedPacketPruneInterval = 5 s.
private static readonly long CachePruneIntervalTicks = TimeSpan.FromSeconds(5).Ticks;
/// NetworkSession.cs:72 — cachedPacketRetentionTime = 120 s.
private const int CachedPacketRetentionSeconds = 120;
/// SessionTerminationDetails.cs:12 — TerminationEndTicks = start + 2 s.
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;
/// C2S verifier — SessionConnectionData.CryptoClient (SessionConnectionData.cs:61).
public AceCryptoModel Crypto { get; }
/// S2C keystream — SessionConnectionData.IssacServer (SessionConnectionData.cs:62).
private readonly IsaacRandom _s2cKeystream;
// ---- receive state ----
/// NetworkSession.cs:57 — starts at 1.
private uint _lastReceivedPacketSequence = 1;
/// NetworkSession.cs:58 — starts at 0.
private uint _lastReceivedFragmentSequence;
/// NetworkSession.cs:41 — outOfOrderPackets (parsed + CRC-verified; never re-verified).
private readonly Dictionary _outOfOrderPackets = new();
/// NetworkSession.cs:42 — partialFragments (multi-fragment C2S reassembly).
private readonly Dictionary _partialFragments = new();
/// NetworkSession.cs:43 — outOfOrderFragments (the C2S fragment gate buffer).
private readonly Dictionary _outOfOrderFragments = new();
/// NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null).
private long? _lastNakTimestamp;
// ---- send state ----
///
/// 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.
///
private uint _packetSequence = uint.MaxValue;
/// SessionConnectionData.cs:36 — FragmentSequence, default 0; assigned at bundle flush (NetworkSession.cs:821).
private uint _s2cFragmentSequence;
/// NetworkSession.cs:65 — cachedPackets, keyed by sequence.
private readonly Dictionary _cachedPackets = new();
private long? _lastPruneTimestamp;
private long _nextAckTimestamp;
private long? _nextResyncTimestamp;
private bool _sendResync;
/// NetworkSession.cs:38-39 — one NetworkBundle per GameMessageGroup.
private readonly PendingBundle[] _bundles = new PendingBundle[QueueMax];
/// NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update.
private readonly Queue _flushQueue = new();
// ---- termination state ----
private long _terminationEndTimestamp;
// ---- observable outputs ----
private readonly List _dispatchedMessages = new();
private readonly List _sentDatagrams = new();
private readonly Queue _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 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 ----
/// Session.State (Session.cs:36) — the value CheckState reads.
public AceSessionState State { get; private set; } = AceSessionState.AuthLoginRequest;
public uint LastReceivedPacketSequence => _lastReceivedPacketSequence;
public uint LastReceivedFragmentSequence => _lastReceivedFragmentSequence;
/// Fully-assembled C2S message bodies in ACE dispatch order.
public IReadOnlyList DispatchedMessages => _dispatchedMessages;
/// Every S2C datagram the model has emitted, in send order (cumulative).
public IReadOnlyList SentDatagrams => _sentDatagrams;
///
/// True once Session.Terminate has armed PendingTermination
/// (Session.cs:281-298). Inbound and outbound keep running for the ~2 s
/// termination window — see for the point of no
/// return.
///
public bool IsTerminated => TerminationPhase is not null;
/// Session.PendingTermination.TerminationStatus (Session.cs:56, :126-131).
public AceTerminationPhase? TerminationPhase { get; private set; }
///
/// 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.
///
public bool IsReleased { get; private set; }
public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None;
/// VirtualClock timestamp at or past which terminates the session.
public long TimeoutDeadlineTimestamp { get; private set; }
public int OutOfOrderPacketCount => _outOfOrderPackets.Count;
/// Completed messages parked behind the C2S fragment gate (NetworkSession.cs:539-542).
public int FragmentGateBufferCount => _outOfOrderFragments.Count;
public int PartialFragmentBufferCount => _partialFragments.Count;
public int CachedPacketCount => _cachedPackets.Count;
public IReadOnlyCollection CachedPacketSequences => _cachedPackets.Keys;
/// Packets silently dropped by CRC/Search failure (NetworkSession.cs:277-280).
public int CrcDropCount { get; private set; }
/// Packets dropped by the duplicate-rejection rule (NetworkSession.cs:342-347).
public int DuplicateDropCount { get; private set; }
/// Packets dropped by the Session.CheckState gate (Session.cs:93-110) — before CRC.
public int StateDropCount { get; private set; }
public int RetransmitsServed { get; private set; }
// ---- script hooks for FakeAceTransport ----
/// Fired when a LoginRequest packet is handled (NetworkSession.cs:463-468).
public event Action? LoginRequestReceived;
/// Fired when a cookie-matching ConnectResponse is accepted (NetworkManager.cs:50-79).
public event Action? ConnectResponseAccepted;
/// Fired per dispatched C2S message body, in ACE dispatch order.
public event Action? MessageDispatched;
/// Drain the datagrams emitted since the last call, in send order.
public List TakePendingDatagrams()
{
var drained = new List(_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 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? uncached = null;
foreach (uint sequence in packet.Optional.RetransmitRequests)
{
if (!TryRetransmit(sequence))
(uncached ??= new List()).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();
}
///
/// Session.CheckState (Session.cs:93-105). Three drops, all of them
/// BEFORE NetworkSession.ProcessPacket and therefore before
/// ClientPacket.VerifyCRC — no keystream word is consumed, no
/// watermark moves, no CRC counter ticks. Note ACE's
/// PacketHeader.HasFlag is ANY-of, not all-of
/// (PacketHeader.cs:70), so the fourth line drops a packet carrying ANY
/// of the four control flags.
///
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;
}
/// ClientPacket.VerifyCRC (ClientPacket.cs:138-163) over the crypto model.
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;
}
///
/// NetworkManager.cs:50-79 — ConnectResponse routing. The double only
/// supports the exact shape retail/acdream sends (flags ==
/// ConnectResponse alone, 8-byte cookie body). The
/// State == AuthConnectResponse half of NetworkManager's session
/// lookup (:64) is enforced by above.
///
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();
}
/// NetworkSession.HandleOrderedPacket (:435-477).
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;
}
}
/// NetworkSession.FlagEcho (:650-661).
private void FlagEcho(float clientTime)
{
PendingBundle bundle = _bundles[(int)GameMessageGroup.InvalidQueue];
bundle.ClientTime = clientTime;
bundle.EncryptedChecksum = true;
}
/// NetworkSession.ProcessFragment (:483-544).
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);
}
/// NetworkSession.HandleFragment (:550-554).
private void HandleFragment(byte[] message)
{
_dispatchedMessages.Add(message);
MessageDispatched?.Invoke(message);
_lastReceivedFragmentSequence++;
}
/// NetworkSession.CheckOutOfOrderPackets (:559-566).
private void CheckOutOfOrderPackets()
{
while (_outOfOrderPackets.Remove(_lastReceivedPacketSequence + 1, out ParsedPacket? packet))
HandleOrderedPacket(packet);
}
/// NetworkSession.CheckOutOfOrderFragments (:571-578).
private void CheckOutOfOrderFragments()
{
while (_outOfOrderFragments.Remove(_lastReceivedFragmentSequence + 1, out byte[]? message))
HandleFragment(message);
}
/// NetworkSession.AcknowledgeSequence (:663-673) — prune strictly-older
/// cached S2C packets. Raw uint compare (`x < sequence`), NOT wrap-safe:
/// modeled exactly as ACE does it.
private void AcknowledgeSequence(uint sequence)
{
List? removal = null;
foreach (uint key in _cachedPackets.Keys)
{
if (key < sequence)
(removal ??= new List()).Add(key);
}
if (removal is null)
return;
foreach (uint key in removal)
_cachedPackets.Remove(key);
}
/// NetworkSession.DoRequestForRetransmission (:387-426).
private void DoRequestForRetransmission(uint rcvdSeq)
{
uint desiredSeq = _lastReceivedPacketSequence + 1; // :389
var needSeq = new List { 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
}
/// NetworkSession.Retransmit (:675-708) — serve a NAKed id from the cache.
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;
}
/// 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).
private void EnqueueRejectRetransmit(List 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.
// =====================================================================
///
/// 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.
///
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
}
/// NetworkSession.Update (:182-249).
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);
}
///
/// 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 , coalesced
/// with whatever else is in the same bundle.
///
public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group)
{
PendingBundle bundle = _bundles[(int)group];
bundle.EncryptedChecksum = true;
bundle.Enqueue(gameMessageBody);
}
///
/// 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 .
///
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
}
///
/// 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 Count > 1 fragments.
///
///
/// 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).
///
///
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();
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();
byte[] optionalBytes = Array.Empty();
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();
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
}
}
///
/// 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.
///
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 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();
}
/// Concatenate the optional section and the packet's fragments into
/// one flush draft (ServerPacket.Data + ServerPacket.Fragments).
private static OutboundDraft BuildDraft(
PacketHeaderFlags flags,
byte[] optionalBytes,
List 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);
}
/// FlushPackets, per packet (:710-735) + SendPacket (:737-752).
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);
}
/// UIntSequence.NextValue (UIntSequence.cs:30-41): wrap max → 0.
private uint NextPacketSequence()
{
_packetSequence = _packetSequence == uint.MaxValue ? 0u : _packetSequence + 1u;
return _packetSequence;
}
/// ServerPacket.CreateReadyToSendPacket (ServerPacket.cs:46-72).
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);
}
/// ServerPacket.cs:48-62 — Hash32(data section) + Σ fragment hashes.
private static uint ComputePayloadHash(
ReadOnlySpan body,
PacketHeaderFlags flags,
int optionalLength)
{
uint hash = Hash32.Calculate(body.Slice(0, optionalLength));
if ((flags & PacketHeaderFlags.BlobFragments) == 0)
return hash;
ReadOnlySpan 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;
}
/// 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.
private void PruneCachedPackets()
{
_lastPruneTimestamp = _clock.GetTimestamp(); // :253
ushort currentTime = (ushort)(long)_clock.Seconds; // :255
List? 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()).Add(packet.Sequence);
}
}
if (removal is null)
return;
foreach (uint sequence in removal)
_cachedPackets.Remove(sequence);
}
///
/// 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
/// . A second Terminate overwrites the details, exactly
/// like ACE (:293).
///
private void Terminate(AceTerminationReason reason)
{
TerminationReason = reason;
TerminationPhase = AceTerminationPhase.Initialized;
_terminationEndTimestamp = _clock.GetTimestamp() + TerminationWindowTicks;
}
/// NetworkSession.ReleaseResources (:958-974), reached through
/// Session.DropSession (:300-334).
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 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 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();
uint fragmentHash = 0;
if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
{
// ClientPacket.ReadFragments (:54-76) + fragmentChecksum (:84-101).
ReadOnlySpan 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;
}
///
/// ClientPacketFragment.Unpack (ClientPacketFragment.cs:10-23) — ACE's
/// COMPLETE inbound fragment validation: a 16-byte header, then
/// Size − 16 >= 0 (:14-15) and Size <= 464 (:17-18).
/// Deliberately looser than acdream's production
/// MessageFragment.TryParseLayout, which additionally rejects
/// Count == 0 and Index >= Count: the double's C2S parse
/// path exists to characterize ACE, so it must accept everything ACE
/// accepts. ACE's BinaryReader.ReadBytes 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.
///
private static bool TryParseClientFragment(
ReadOnlySpan 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;
}
///
/// 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).
///
private sealed record ParsedPacket(
PacketHeader Header,
PacketHeaderOptional Optional,
List Fragments,
uint FragmentHash);
private readonly record struct OutboundDraft(
PacketHeaderFlags Flags,
byte[] Body,
int OptionalLength);
/// The cached ServerPacket surrogate — see FlushOne/TryRetransmit.
private sealed class CachedS2CPacket
{
public uint Sequence;
public PacketHeaderFlags Flags;
public ushort Time;
public byte[] Body = Array.Empty();
public int OptionalLength;
public uint IsaacXor;
}
/// ACE NetworkBundle surrogate (NetworkBundle.cs:6-63) — one per
/// GameMessageGroup, swapped out whole when it needs sending.
private sealed class PendingBundle
{
private readonly Queue _messages = new();
private bool _propChanged;
/// NetworkBundle.cs:11.
public bool NeedsSending => _propChanged || _messages.Count > 0;
/// NetworkBundle.cs:13.
public bool HasMoreMessages => _messages.Count > 0;
private float _clientTime = -1f;
/// NetworkBundle.cs:18-27 — -1f means "no echo pending".
public float ClientTime
{
get => _clientTime;
set { _clientTime = value; _propChanged = true; }
}
private bool _timeSync;
/// NetworkBundle.cs:29-38.
public bool TimeSync
{
get => _timeSync;
set { _timeSync = value; _propChanged = true; }
}
private bool _sendAck;
/// NetworkBundle.cs:40-49.
public bool SendAck
{
get => _sendAck;
set { _sendAck = value; _propChanged = true; }
}
/// NetworkBundle.cs:51.
public bool EncryptedChecksum { get; set; }
public void Enqueue(byte[] message) => _messages.Enqueue(message);
public byte[] Dequeue() => _messages.Dequeue();
}
///
/// ACE's server-side MessageFragment (MessageFragment.cs:10-103): one
/// queued GameMessage plus the split bookkeeping SendBundle drives.
///
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;
/// MessageFragment.cs:27-36.
public int NextSize =>
MessageFragmentHeader.Size
+ Math.Min(DataRemaining, MessageFragmentHeader.MaxFragmentDataSize);
/// MessageFragment.cs:38.
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
}
/// MessageFragment.cs:54-59.
public MessageFragment GetTailFragment()
{
var index = (ushort)(Count - 1);
TailSent = true;
return CreateFragment(index);
}
/// MessageFragment.cs:61-64.
public MessageFragment GetNextFragment() => CreateFragment(_index++);
/// MessageFragment.cs:66-102.
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);
}
}
///
/// ACE MessageBuffer surrogate (MessageBuffer.cs:7-54). Deliberately a
/// LIST keyed on nothing, exactly like ACE: TotalFragments 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.
///
private sealed class PartialC2SMessage
{
private readonly List<(ushort Index, byte[] Payload)> _fragments = new();
private readonly int _totalFragments;
public PartialC2SMessage(int totalFragments) => _totalFragments = totalFragments;
/// MessageBuffer.cs:14.
public bool Complete => _fragments.Count == _totalFragments;
/// MessageBuffer.cs:22-31 — ignored once complete, and one
/// fragment per Index.
public void AddFragment(ushort index, byte[] payload)
{
if (Complete)
return;
foreach ((ushort existing, _) in _fragments)
{
if (existing == index)
return;
}
_fragments.Add((index, payload));
}
///
/// 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.
///
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;
}
}
}