test(net): N0 - ACE-behaviour double, virtual clock, lossy link

Campaign N slice N0 (docs/plans/2026-07-29-network-transport-campaign.md):
the referee that slices N1-N5 are graded against, test-project only, zero
production changes.

- VirtualClock: Stopwatch-shaped deterministic time source (fixed 100 ns
  ticks) that N1 will inject behind the production TransportClock.
- AceCryptoModel: verbatim port of ACE CryptoSystem Search/ConsumeKey over
  our IsaacRandom - 256-key window, parked-key set, Headroom/OrphanCount
  diagnostics (CryptoSystem.cs:8-49 cited per method).
- AceSessionModel: transport-free ACE NetworkSession over raw datagrams,
  every rule cited to NetworkSession.cs - CRC-before-everything silent
  drop, cleartext-NAK early return (no timeout refresh, :283-308),
  60 s timeout refresh (:329-331), exact-equality ack dedup exemption
  (:342-347), desired+2 NAK trigger with 1 s limit (:351-363), >window
  AbnormalSequenceReceived (:393-397), the :474-476 watermark hole,
  ack-value cache prune (:663-673), fragment gate (:532-543), seq>=2
  caching (:730), Retransmission-flag resends with the ORIGINAL IssacXor
  (:675-686), RejectRetransmit, 2 s cleartext cumulative ack, 20 s
  TimeSync, EchoResponse, 120 s cache prune (:251-262). ACE's raw
  wrap-unsafe comparisons are modeled bug-for-bug, not fixed.
- LossyLink: deterministic drop/reorder/seeded-loss fault injector, pure
  data structure.
- FakeAceTransport: IWorldSessionTransport binding a REAL WorldSession to
  the model through the link, with the handshake scripted (ConnectRequest
  reusing the negotiation fixture layout, CharacterList, ServerReady,
  logoff confirmation) - genuine Connect/EnterWorld/Tick/Dispose with no
  sockets.
- 19 new tests pin the double, including
  CleartextNonAckAdvancesWatermark_TheAceHole (the self-induced wedge
  behind scope rows TS-57/TS-58/AP-125), re-key = permanent orphan,
  unrequested-resend window burn, the 115-id NAK cap boundary, and a
  full no-socket session lifecycle with both ISAAC streams verified
  aligned end-to-end.

Core.Net suite: 678 passed / 0 failed (659 existing + 19 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 11:07:13 +02:00
parent 9ed43e27df
commit 7e9134b4d1
7 changed files with 2359 additions and 0 deletions

View file

@ -0,0 +1,112 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Cryptography;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// Faithful port of ACE's server-side C2S checksum-key discipline
/// (<c>references/ACE/Source/ACE.Common/Cryptography/CryptoSystem.cs</c>)
/// over acdream's <see cref="IsaacRandom"/>. This is the exact machinery
/// Coldeve uses to verify every encrypted client packet, so the double must
/// reproduce its behavior word-for-word — in particular the 256-key search
/// window, the parked-key set ("xors"), and the way an unexpected key
/// permanently orphans window capacity.
///
/// <para>
/// Behavior summary (all ACE, none invented):
/// <list type="bullet">
/// <item>The keystream is one ISAAC word per ENCRYPTED packet, in the
/// order the client SENT them (drew them), not arrival order.</item>
/// <item><see cref="Search"/> walks forward at most
/// <see cref="MaximumEffortLevel"/> |xors| words hunting for the
/// presented key, parking every skipped word in the xors set.</item>
/// <item><see cref="ConsumeKey"/> advances the wheel when the presented
/// key is the current one, otherwise un-parks it from xors.</item>
/// <item>A key that is BEHIND the wheel (already consumed) can never be
/// found again — searching for it burns the remaining window.</item>
/// </list>
/// </para>
/// </summary>
internal sealed class AceCryptoModel
{
/// <summary>CryptoSystem.cs:8 — the 256-key search window.</summary>
public const int MaximumEffortLevel = 256;
private readonly IsaacRandom _keystream;
/// <summary>
/// CryptoSystem.cs:9 — keys the search walked past while hunting for an
/// out-of-order arrival, parked so the retransmission (carrying the
/// ORIGINAL key) can still verify.
/// </summary>
private readonly HashSet<uint> _xors = new();
/// <summary>CryptoSystem.cs:10 — the next expected keystream word.</summary>
public uint CurrentKey { get; private set; }
/// <summary>
/// CryptoSystem.cs:11-14 — seed the ISAAC wheel and pre-draw the first
/// key. ACE's <c>CryptoSystem(uint seed)</c> passes
/// <c>BitConverter.GetBytes(seed)</c> (little-endian), which we mirror.
/// </summary>
public AceCryptoModel(uint seed)
{
Span<byte> seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
_keystream = new IsaacRandom(seedBytes);
CurrentKey = _keystream.Next();
}
/// <summary>
/// Remaining search capacity: 256 |xors|. Every parked key that is
/// never consumed (an orphan — e.g. from a re-keyed resend) shrinks this
/// permanently. When it reaches zero, the next packet loss is
/// unrecoverable.
/// </summary>
public int Headroom => MaximumEffortLevel - _xors.Count;
/// <summary>
/// Number of currently parked keys. A parked key is either a pending
/// retransmission's original key (healthy, recovered on arrival) or a
/// permanent orphan (the client re-keyed the resend and the original
/// word will never be presented).
/// </summary>
public int OrphanCount => _xors.Count;
/// <summary>
/// CryptoSystem.cs:19-29 — advance the wheel if <paramref name="x"/> is
/// the current key; otherwise remove it from the parked set.
/// </summary>
public void ConsumeKey(uint x)
{
if (CurrentKey == x)
CurrentKey = _keystream.Next();
else
_xors.Remove(x);
}
/// <summary>
/// CryptoSystem.cs:30-49 — is <paramref name="x"/> the current key, a
/// parked key, or reachable within the remaining search window? Walking
/// parks every skipped word. Verbatim port including the loop bound
/// being captured BEFORE the walk starts.
/// </summary>
public bool Search(uint x)
{
if (CurrentKey == x)
return true;
if (_xors.Contains(x))
return true;
int g = _xors.Count;
for (int i = 0; i < MaximumEffortLevel - g; i++)
{
_xors.Add(CurrentKey);
ConsumeKey(CurrentKey);
if (CurrentKey == x)
return true;
}
return false;
}
}

View file

@ -0,0 +1,959 @@
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, :329-331) expired — every ACE transport death is silence.</summary>
NetworkTimeout,
}
/// <summary>
/// Transport-free model of ACE's per-connection <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>
/// 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 the WorldManager loop).</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>Each enqueued game message flushes as its own packet; ACE
/// coalesces same-group fragments up to 464 bytes
/// (NetworkSession.cs:828-918). Sequencing/caching semantics are
/// identical either way because our messages are all ≤448 B.</item>
/// <item>Ack/TimeSync/EchoResponse 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).</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>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;
// ---- 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;
private float? _pendingEchoClientTime;
// ---- 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.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;
private bool _handshakeComplete;
private readonly List<(byte[] Body, GameMessageGroup Group)> _pendingMessages = new();
/// <summary>NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update.</summary>
private readonly Queue<OutboundDraft> _flushQueue = new();
// ---- 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);
// 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 ----
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;
public bool IsTerminated { get; private set; }
public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None;
/// <summary>VirtualClock timestamp 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; }
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 — NetworkSession.ProcessPacket (:269-379), in ACE's
// exact order.
// =====================================================================
public void Receive(ReadOnlySpan<byte> datagram)
{
if (IsTerminated)
return; // isReleased guard (:271-272)
if (!TryParse(datagram, out ParsedPacket packet))
return; // ClientPacket.Unpack failure — ConnectionListener discards silently
// 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).
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>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).
/// </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
if (_handshakeComplete)
return; // NetworkManager.cs:64-65 — session must still be in AuthConnectResponse
_handshakeComplete = true;
_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
// next control-bundle flush.
if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0)
_pendingEchoClientTime = 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.ProcessFragment (:483-544).</summary>
private void ProcessFragment(MessageFragment fragment)
{
byte[]? message = null;
if (fragment.Header.Count != 1)
{
// :489-518 — split message, buffered by fragment sequence.
if (!_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer))
{
buffer = new PartialC2SMessage(fragment.Header.Count);
_partialFragments.Add(fragment.Header.Sequence, buffer);
}
buffer.Add(fragment.Header.Index, fragment.Payload);
if (buffer.Complete)
{
message = buffer.Assemble();
_partialFragments.Remove(fragment.Header.Sequence);
}
}
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).
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 — NetworkSession.Update (:182-249) + FlushPackets (:710-735)
// + SendPacket (:737-752), driven by the virtual clock.
// =====================================================================
/// <summary>
/// One server pump: timeout check, cache prune, control bundle
/// (ack/TimeSync/EchoResponse), message bundles, then FlushPackets.
/// ACE runs this from the world tick; the double runs it whenever the
/// harness pumps.
/// </summary>
public void Update()
{
if (IsTerminated)
return;
// WorldManager's TimeoutTick check (NetworkSession.cs:88). Every ACE
// transport death is silence — no disconnect packet is ever sent.
if (_clock.GetTimestamp() > TimeoutDeadlineTimestamp)
{
Terminate(AceTerminationReason.NetworkTimeout);
return;
}
// :187-188 — prune the S2C cache every 5 s.
if (_lastPruneTimestamp is null
|| _clock.GetTimestamp() - _lastPruneTimestamp.Value > CachePruneIntervalTicks)
{
PruneCachedPackets();
}
if (_handshakeComplete)
{
BuildControlDraft();
FlushMessageBundles();
}
// 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. Flushed by the next <see cref="Update"/>
/// as its own BlobFragments|EncryptedChecksum packet (EnqueueSend sets
/// EncryptedChecksum, NetworkSession.cs:129).
/// </summary>
public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group) =>
_pendingMessages.Add((gameMessageBody, group));
/// <summary>
/// AuthenticationHandler → PacketOutboundConnectRequest: 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.
/// </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));
}
/// <summary>
/// The InvalidQueue control bundle (:203-216) written per
/// WriteOptionalHeaders (:921-948): ack value, then TimeSync, then
/// EchoResponse. A pure ack is CLEARTEXT with flags exactly AckSequence;
/// TimeSync and EchoResponse force EncryptedChecksum (:207, :659), so a
/// coalesced ack+TimeSync packet is encrypted and sequenced — exactly
/// ACE's behavior when both timers fire in one pump.
/// </summary>
private void BuildControlDraft()
{
bool resyncDue = _sendResync
&& (_nextResyncTimestamp is null
|| _clock.GetTimestamp() > _nextResyncTimestamp.Value); // :203 (+ :47-50 immediate first send)
bool ackDue = _clock.GetTimestamp() > _nextAckTimestamp; // :211 (sendAck is always true, :54)
bool echoDue = _pendingEchoClientTime is not null; // :941 (ClientTime != -1)
if (!resyncDue && !ackDue && !echoDue)
return;
var flags = PacketHeaderFlags.None;
var writer = new PacketWriter(24);
if (ackDue)
{
flags |= PacketHeaderFlags.AckSequence; // :925-931
writer.WriteUInt32(_lastReceivedPacketSequence);
_nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks; // :215
}
if (resyncDue)
{
flags |= PacketHeaderFlags.TimeSync | PacketHeaderFlags.EncryptedChecksum; // :933-938 + :207
Span<byte> value = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(
value,
BitConverter.DoubleToInt64Bits(_clock.Seconds));
writer.WriteBytes(value);
_nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks; // :208
}
if (echoDue)
{
flags |= PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum; // :941-948 + :659
writer.WriteFloat(_pendingEchoClientTime!.Value);
writer.WriteFloat((float)_clock.Seconds - _pendingEchoClientTime.Value);
_pendingEchoClientTime = null;
}
byte[] body = writer.ToArray();
_flushQueue.Enqueue(new OutboundDraft(flags, body, OptionalLength: body.Length));
}
/// <summary>
/// SendBundle for queued game messages: groups flush in ascending group
/// order (:190-194), fragment sequences are assigned at flush time
/// (:821) starting from 0 (SessionConnectionData.FragmentSequence), and
/// the fragment Id is the constant 0x80000000 (ACE MessageFragment.cs:94).
/// One packet per message — see the class-doc simplification note.
/// </summary>
private void FlushMessageBundles()
{
if (_pendingMessages.Count == 0)
return;
foreach ((byte[] body, GameMessageGroup group) in
_pendingMessages.OrderBy(m => (int)m.Group)) // OrderBy is stable → FIFO within a group
{
MessageFragment fragment = GameMessageFragment.BuildSingleFragment(
_s2cFragmentSequence++,
group,
body);
byte[] fragmentBytes = GameMessageFragment.Serialize(fragment);
_flushQueue.Enqueue(new OutboundDraft(
PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
fragmentBytes,
OptionalLength: 0));
}
_pendingMessages.Clear();
}
/// <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)
{
(MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining);
if (fragment is null)
throw new InvalidOperationException("the model built a malformed fragment");
hash += PacketCodec.CalculateFragmentHash32(fragment.Value);
remaining = remaining.Slice(consumed);
}
return hash;
}
/// <summary>NetworkSession.PruneCachedPackets (:251-262) — 120 s retention
/// with ACE's ushort-wrap guard expression, verbatim.</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);
}
private void Terminate(AceTerminationReason reason)
{
IsTerminated = true;
TerminationReason = reason;
}
// =====================================================================
// 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)
{
(MessageFragment? fragment, int consumed) = MessageFragment.TryParse(remaining);
if (fragment is null)
return false;
fragments.Add(fragment.Value);
fragmentHash += PacketCodec.CalculateFragmentHash32(fragment.Value);
remaining = remaining.Slice(consumed);
}
}
packet = new ParsedPacket(header, optional, fragments, fragmentHash);
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 MessageBuffer surrogate (NetworkSession.cs:495-518).</summary>
private sealed class PartialC2SMessage
{
private readonly byte[]?[] _parts;
private int _received;
public PartialC2SMessage(int totalFragments) =>
_parts = new byte[totalFragments][];
public bool Complete => _received == _parts.Length;
public void Add(int index, byte[] payload)
{
if (_parts[index] is not null)
return; // duplicate index — idempotent
_parts[index] = payload;
_received++;
}
public byte[] Assemble()
{
int total = 0;
foreach (byte[]? part in _parts)
total += part!.Length;
byte[] message = new byte[total];
int offset = 0;
foreach (byte[]? part in _parts)
{
byte[] bytes = part!;
bytes.CopyTo(message.AsSpan(offset));
offset += bytes.Length;
}
return message;
}
}
}

View file

@ -0,0 +1,668 @@
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>
/// Tests OF the ACE-behaviour double — they pin the model against the ACE
/// source rules cited inside <see cref="AceSessionModel"/> so slices N1-N5
/// can trust it as the referee. They do not test acdream production code.
/// </summary>
public sealed class AceSessionModelTests
{
private const uint ClientSeed = 0x11AA22BBu;
private const uint ServerSeed = 0x33CC44DDu;
private const uint ClientId = 0x1234u;
private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
[Fact]
public void Nak_FiresOnlyAtDesiredPlusTwo_WithOneSecondRateLimit()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
byte[][] packets = BuildSequentialPackets(client, count: 5); // seq 2..6
// Gap of one: desired = 2, arrived = 3 → desired+2 (4) > 3 → buffered, NO NAK
// (NetworkSession.cs:351-363 — ACE needs two arrivals past the gap).
model.Receive(packets[1]);
model.Update();
Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
Assert.Equal(1, model.OutOfOrderPacketCount);
Assert.Equal(1u, model.LastReceivedPacketSequence);
// Second arrival past the gap: desired+2 (4) <= 4 → NAK fires, cleartext,
// flags exactly RequestRetransmit, listing only the truly missing id.
model.Receive(packets[2]);
model.Update();
byte[] nak = Assert.Single(
OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
Assert.Equal(new uint[] { 2u }, NakIds(nak));
// Within the 1 s limit (:359) another eligible arrival does NOT re-NAK.
model.Receive(packets[3]);
model.Update();
Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
// Limiter reopens strictly after 1 s.
clock.Advance(TimeSpan.FromSeconds(1.1));
model.Receive(packets[4]);
model.Update();
byte[] second = Assert.Single(
OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
Assert.Equal(new uint[] { 2u }, NakIds(second));
}
[Fact]
public void ValidResend_IsAccepted_AndOrderingRestored()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[][] packets = BuildSequentialPackets(client, 3); // seq 2(w1), 3(w2), 4(w3)
model.Receive(packets[0]); // in order
model.Receive(packets[2]); // out of order: Search parks w2, consumes w3
Assert.Single(model.DispatchedMessages);
Assert.Equal(255, model.Crypto.Headroom);
// A CORRECT retransmission is byte-identical (same sequence, same
// keystream word). The parked key verifies it (CryptoSystem.cs:36-39)
// and ConsumeKey un-parks it — the window fully recovers, and the
// buffered packet replays in order (NetworkSession.cs:559-566).
model.Receive(packets[1]);
Assert.Equal(new byte[] { 2, 3, 4 }, Markers(model));
Assert.Equal(4u, model.LastReceivedPacketSequence);
Assert.Equal(0, model.OutOfOrderPacketCount);
Assert.Equal(256, model.Crypto.Headroom);
Assert.Equal(0, model.Crypto.OrphanCount);
}
[Fact]
public void ReKeyedResend_PermanentlyOrphansAKeystreamWord()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[][] packets = BuildSequentialPackets(client, 3); // seq 2(w1), 3(w2), 4(w3)
model.Receive(packets[0]);
model.Receive(packets[2]); // parks w2 for the pending retransmission
Assert.Equal(255, model.Crypto.Headroom);
// The buggy client re-keys the resend of seq 3: a fresh encode draws
// w4 — which is exactly the server's CurrentKey (the gap walk mirrored
// the client's consumption), so ACE ACCEPTS the packet... but the
// parked ORIGINAL w2 is now orphaned: no future packet will ever
// present it, and the 256-key window is one slot smaller FOREVER.
// This is campaign doc §3 row 2 / landmine #2: NEVER re-key a resend —
// every loss+re-key cycle burns another slot until the window is gone.
byte[] rekeyed = client.BuildGameMessagePacket(
packetSequence: 3,
fragmentSequence: 2,
MakeMessage(3));
model.Receive(rekeyed);
Assert.Equal(new byte[] { 2, 3, 4 }, Markers(model)); // accepted, ordering restored
Assert.Equal(255, model.Crypto.Headroom);
Assert.Equal(1, model.Crypto.OrphanCount);
// Healthy follow-on traffic never recovers the orphan.
model.Receive(client.BuildGameMessagePacket(MakeMessage(5))); // seq 5
model.Receive(client.BuildGameMessagePacket(MakeMessage(6))); // seq 6
Assert.Equal(new byte[] { 2, 3, 4, 5, 6 }, Markers(model));
Assert.Equal(255, model.Crypto.Headroom);
Assert.Equal(1, model.Crypto.OrphanCount);
}
[Fact]
public void ResendOfAlreadyAcceptedPacket_BurnsTheSearchWindow()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[][] packets = BuildSequentialPackets(client, 2); // seq 2(w1), 3(w2)
model.Receive(packets[0]); // accepted — w1 consumed, wheel at w2
Assert.Single(model.DispatchedMessages);
// An UNREQUESTED duplicate of an already-accepted packet: VerifyCRC
// runs BEFORE dedup (NetworkSession.cs:277 vs :342), and w1 is now
// BEHIND the wheel — Search walks the entire remaining window
// (parking all 256 keys) and fails. Silent drop, window at zero.
// Campaign doc §3 row 2 / landmine #3: never resend unrequested.
model.Receive(packets[0]);
Assert.Equal(1, model.CrcDropCount);
Assert.Single(model.DispatchedMessages);
Assert.Equal(0, model.Crypto.Headroom);
Assert.Equal(256, model.Crypto.OrphanCount);
// ACE's parked set doubles as the recovery path: the next healthy
// packet's key (w2) was parked during the walk, so it still verifies
// and un-parks — the window drains back one packet at a time.
model.Receive(packets[1]);
Assert.Equal(2, model.DispatchedMessages.Count);
Assert.Equal(1, model.Crypto.Headroom);
}
[Fact]
public void AckOnlyPacketAtSameSequence_AcceptedWithoutAdvancingWatermark()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
// The negotiated model has one cached S2C packet: the immediate
// first TimeSync at sequence 2.
Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray());
model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // client seq 2 → watermark 2
Assert.Equal(2u, model.LastReceivedPacketSequence);
model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue);
model.Update(); // flushes as S2C sequence 3, cached
Assert.Equal(2, model.CachedPacketCount);
// acdream's acks reuse the last issued client sequence, so they land
// AT the watermark: accepted via the exact-equality exemption
// (NetworkSession.cs:342-343), the ack VALUE prunes the S2C cache
// strictly below it (:663-673), and the watermark does NOT advance
// (:474-476: Flags == AckSequence exactly).
model.Receive(client.BuildCleartextAck(headerSequence: 2, ackValue: 3));
Assert.Equal(0, model.DuplicateDropCount);
Assert.Equal(2u, model.LastReceivedPacketSequence);
Assert.Equal(new uint[] { 3u }, model.CachedPacketSequences.ToArray());
// Repeatable at the same sequence.
model.Receive(client.BuildCleartextAck(2, 4));
Assert.Equal(0, model.DuplicateDropCount);
Assert.Empty(model.CachedPacketSequences);
Assert.Equal(2u, model.LastReceivedPacketSequence);
// The exemption is equality, not <=: an ack at an OLDER sequence is
// rejected as a duplicate.
model.Receive(client.BuildCleartextAck(1, 4));
Assert.Equal(1, model.DuplicateDropCount);
}
[Fact]
public void CleartextNonAckAdvancesWatermark_TheAceHole()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[][] packets = BuildSequentialPackets(client, 2); // seq 2(w1), 3(w2)
model.Receive(packets[0]); // watermark 2
// THE ACE HOLE (campaign doc §3 row 3, NetworkSession.cs:474-476):
// the watermark advances for ANY packet whose flags are not exactly
// AckSequence — including a cleartext control packet (here an
// EchoRequest keepalive) that reuses a live sequence number.
model.Receive(client.BuildCleartextEchoRequest(headerSequence: 3, clientTime: 1.5f));
Assert.Equal(3u, model.LastReceivedPacketSequence);
// The REAL packet at sequence 3 arrives: its CRC verifies (the
// keystream stays aligned — the word is consumed properly), but the
// dedup stage (:342-347) drops the payload. The message is gone
// FOREVER and ACE will never NAK it — the self-induced wedge that
// forbids standalone non-ack control packets (register AP-125/TS-58).
model.Receive(packets[1]);
Assert.Equal(1, model.DuplicateDropCount);
Assert.Single(model.DispatchedMessages);
Assert.Equal(3u, model.LastReceivedPacketSequence);
Assert.Equal(256, model.Crypto.Headroom); // no orphan — the loss is pure payload
}
[Fact]
public void FragmentGate_StallsOnGap_AndHealsWhenMissingFragmentArrives()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
// Three in-order PACKETS carrying out-of-order FRAGMENT sequences:
// packet 2 → fragment 1, packet 3 → fragment 3, packet 4 → fragment 2.
// This isolates the C2S fragment gate (NetworkSession.cs:532-543)
// from packet-level reordering. (The packet-retransmission flavor of
// the heal is covered by ValidResend_IsAccepted_AndOrderingRestored.)
byte[] first = client.BuildGameMessagePacket(2, 1, MakeMessage(1));
byte[] third = client.BuildGameMessagePacket(3, 3, MakeMessage(3));
byte[] second = client.BuildGameMessagePacket(4, 2, MakeMessage(2));
model.Receive(first);
Assert.Equal(new byte[] { 1 }, Markers(model));
// The packet is accepted (in order at the packet level) but the
// completed message stalls silently behind the gate.
model.Receive(third);
Assert.Equal(3u, model.LastReceivedPacketSequence);
Assert.Equal(new byte[] { 1 }, Markers(model));
Assert.Equal(1, model.FragmentGateBufferCount);
Assert.Equal(1u, model.LastReceivedFragmentSequence);
// The missing fragment arrives (here aboard the next packet — on a
// real link, via packet retransmission): the gate dispatches it and
// drains the parked fragment in order (:571-578).
model.Receive(second);
Assert.Equal(new byte[] { 1, 2, 3 }, Markers(model));
Assert.Equal(0, model.FragmentGateBufferCount);
Assert.Equal(3u, model.LastReceivedFragmentSequence);
}
[Fact]
public void SixtySecondTimeout_Terminates_AndCleartextNaksDoNotRefreshIt()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // refresh → +60 s (:329-331)
clock.Advance(TimeSpan.FromSeconds(59));
// A cleartext NAK is handled and RETURNS before the timeout refresh
// (:283-308) — it does NOT extend the deadline. (Id 2 is the cached
// initial TimeSync, so this one is served, proving the path ran.)
model.Receive(client.BuildCleartextNak(2, 2u));
Assert.Equal(1, model.RetransmitsServed);
model.Update();
Assert.False(model.IsTerminated);
clock.Advance(TimeSpan.FromSeconds(2)); // 61 s since the last real packet
model.TakePendingDatagrams();
model.Update();
Assert.True(model.IsTerminated);
Assert.Equal(AceTerminationReason.NetworkTimeout, model.TerminationReason);
// Every ACE transport death is silence — no disconnect packet is sent.
Assert.Empty(model.TakePendingDatagrams());
}
[Fact]
public void GapBeyondSearchWindow_TerminatesAbnormalSequenceReceived()
{
// Boundary: watermark 1 → desired 2 → bottom 3. Arrived 259 keeps
// rcvd bottom == 256 (not > MaximumEffortLevel) → a NAK capped at
// 115 ids (NetworkSession.cs:381, :398-410).
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
model.Receive(client.BuildGameMessagePacket(259, 1, MakeMessage(1)));
Assert.False(model.IsTerminated);
model.Update();
byte[] nak = Assert.Single(
OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
uint[] ids = NakIds(nak);
Assert.Equal(115, ids.Length);
Assert.Equal(2u, ids[0]); // desiredSeq leads the list (:390-391)
Assert.Equal(116u, ids[^1]); // then 3..116 — the 115-id cap
// One past the window: rcvd bottom > 256 → AbnormalSequenceReceived
// (:393-397), and no NAK goes out.
(AceSessionModel model2, TestAcClient client2, _) = CreateNegotiatedModel();
model2.Receive(client2.BuildGameMessagePacket(260, 1, MakeMessage(1)));
Assert.True(model2.IsTerminated);
Assert.Equal(AceTerminationReason.AbnormalSequenceReceived, model2.TerminationReason);
model2.Update();
Assert.Empty(OfExactFlags(model2.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
}
[Fact]
public void Retransmit_ServesCachedBytes_WithRetransmissionFlag_AndNoNewIsaacWord()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
// Shadow the S2C keystream: word 1 went to the immediate TimeSync the
// negotiation helper drained.
IsaacRandom shadow = MakeIsaac(ServerSeed);
uint w1 = shadow.Next();
uint w2 = shadow.Next();
uint w3 = shadow.Next();
uint w4 = shadow.Next();
Assert.NotEqual(w1, w2); // sanity on the shadow itself
model.EnqueueGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
model.Update();
byte[] packetA = Assert.Single(model.TakePendingDatagrams());
Assert.Equal(3u, Head(packetA).Sequence); // TimeSync took 2; UIntSequence increments
Assert.Equal(w2, ExtractIsaacKey(packetA));
model.EnqueueGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue);
model.Update();
byte[] packetB = Assert.Single(model.TakePendingDatagrams());
Assert.Equal(w3, ExtractIsaacKey(packetB));
// Cleartext NAK for sequence 3 → IMMEDIATE retransmit from the cache
// (NetworkSession.cs:675-686): Retransmission OR'd into the flags,
// body bytes untouched, ORIGINAL keystream word reused, Time kept.
model.Receive(client.BuildCleartextNak(2, 3u));
byte[] resent = Assert.Single(model.TakePendingDatagrams());
PacketHeader resentHeader = Head(resent);
Assert.Equal(3u, resentHeader.Sequence);
Assert.Equal(
PacketHeaderFlags.Retransmission
| PacketHeaderFlags.EncryptedChecksum
| PacketHeaderFlags.BlobFragments,
resentHeader.Flags);
Assert.Equal(
packetA.AsSpan(PacketHeader.Size).ToArray(),
resent.AsSpan(PacketHeader.Size).ToArray());
Assert.Equal(w2, ExtractIsaacKey(resent));
Assert.Equal(Head(packetA).Time, resentHeader.Time);
Assert.Equal(1, model.RetransmitsServed);
// The S2C keystream was not disturbed: the next fresh packet uses w4.
model.EnqueueGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue);
model.Update();
byte[] packetC = Assert.Single(model.TakePendingDatagrams());
Assert.Equal(w4, ExtractIsaacKey(packetC));
// A NAK for an id that was never cached → RejectRetransmit (:299-304).
model.Receive(client.BuildCleartextNak(2, 40u));
model.Update();
byte[] reject = Assert.Single(
model.TakePendingDatagrams(),
d => (Head(d).Flags & PacketHeaderFlags.RejectRetransmit) != 0);
Assert.Equal(new uint[] { 40u }, RejectIds(reject));
}
[Fact]
public void CumulativeAck_EveryTwoSeconds_CleartextExactFlags_ReusedSequence()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
model.Receive(client.BuildGameMessagePacket(MakeMessage(2)));
model.Receive(client.BuildGameMessagePacket(MakeMessage(3))); // watermark 3
model.Update();
Assert.Empty(model.TakePendingDatagrams()); // 2 s gate not due (:55, :211)
clock.Advance(TimeSpan.FromSeconds(2.1));
model.Update();
byte[] ack = Assert.Single(model.TakePendingDatagrams());
PacketHeader ackHeader = Head(ack);
// Cleartext, flags EXACTLY AckSequence (:925-931), sequence REUSED —
// the ack borrows the current S2C sequence without incrementing
// (:722-723; the initial TimeSync holds sequence 2).
Assert.Equal(PacketHeaderFlags.AckSequence, ackHeader.Flags);
Assert.Equal(2u, ackHeader.Sequence);
Assert.Equal(
3u,
BinaryPrimitives.ReadUInt32LittleEndian(ack.AsSpan(PacketHeader.Size)));
model.Update(); // gate re-armed (:215) — no second ack
Assert.Empty(model.TakePendingDatagrams());
// The ack really did not consume a sequence: the next message takes 3.
model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue);
model.Update();
Assert.Equal(3u, Head(Assert.Single(model.TakePendingDatagrams())).Sequence);
}
[Fact]
public void EchoRequest_GetsEchoResponse()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
model.Receive(client.BuildCleartextEchoRequest(headerSequence: 2, clientTime: 5.5f));
clock.Advance(TimeSpan.FromSeconds(0.5));
model.Update();
// FlagEcho (:440-443, :650-661) → EchoResponse on the next control
// flush (:941-948): float clientTime + float (serverNow clientTime),
// EncryptedChecksum forced.
byte[] echo = Assert.Single(model.TakePendingDatagrams());
Assert.Equal(
PacketHeaderFlags.EchoResponse | PacketHeaderFlags.EncryptedChecksum,
Head(echo).Flags);
Assert.Equal(
5.5f,
BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size)));
Assert.Equal(
0.5f - 5.5f,
BinaryPrimitives.ReadSingleLittleEndian(echo.AsSpan(PacketHeader.Size + 4)));
}
[Fact]
public void CachedPackets_PruneAfter120Seconds_ThenStaleNakGetsRejectRetransmit()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
Assert.Equal(new uint[] { 2u }, model.CachedPacketSequences.ToArray()); // the t=0 TimeSync
// Keep the session alive across 121 s with periodic client packets
// (each refreshes the 60 s deadline) but no server pumps.
clock.Advance(TimeSpan.FromSeconds(50));
model.Receive(client.BuildGameMessagePacket(MakeMessage(2)));
clock.Advance(TimeSpan.FromSeconds(50));
model.Receive(client.BuildGameMessagePacket(MakeMessage(3)));
clock.Advance(TimeSpan.FromSeconds(21));
model.Receive(client.BuildGameMessagePacket(MakeMessage(4)));
model.Update(); // prune (:251-262): the seq-2 packet is 121 s old (> 120)
Assert.DoesNotContain(2u, model.CachedPacketSequences);
// A stale NAK for the pruned id → RejectRetransmit — the §3 row
// "S2C cache prunes at 120 s; old NAKs get RejectRetransmit".
model.Receive(client.BuildCleartextNak(4, 2u));
model.Update();
byte[] reject = Assert.Single(
model.TakePendingDatagrams(),
d => (Head(d).Flags & PacketHeaderFlags.RejectRetransmit) != 0);
Assert.Equal(new uint[] { 2u }, RejectIds(reject));
Assert.Equal(0, model.RetransmitsServed);
}
[Fact]
public void ConnectRequest_MatchesNegotiationFixtureLayout()
{
var clock = new VirtualClock();
var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie);
model.LoginRequestReceived += model.SendConnectRequest;
model.Receive(BuildLoginRequest());
model.Update();
// The 32-byte optional layout must match what WorldSession.Connect
// parses (and what WorldSessionNegotiationShutdownTests.
// BuildConnectRequest pins): serverTime, cookie, clientId,
// serverSeed, clientSeed, padding.
byte[] connectRequest = Assert.Single(model.TakePendingDatagrams());
PacketCodec.PacketDecodeResult decoded =
PacketCodec.TryDecode(connectRequest, inboundIsaac: null);
Assert.True(decoded.IsOk, decoded.Error.ToString());
Packet packet = decoded.Packet!;
Assert.True(packet.Header.HasFlag(PacketHeaderFlags.ConnectRequest));
Assert.Equal(0u, packet.Header.Sequence); // first NextValue of the unprimed UIntSequence
Assert.Equal((ushort)1, packet.Header.Iteration);
Assert.Equal(Cookie, packet.Optional.ConnectRequestCookie);
Assert.Equal(ClientId, packet.Optional.ConnectRequestClientId);
Assert.Equal(ServerSeed, packet.Optional.ConnectRequestServerSeed);
Assert.Equal(ClientSeed, packet.Optional.ConnectRequestClientSeed);
}
// =====================================================================
// Fixture helpers
// =====================================================================
/// <summary>
/// A model with the handshake completed the way a real session does it:
/// LoginRequest → ConnectRequest (flushed + discarded; primes the S2C
/// sequence to 0) → ConnectResponse → the immediate first TimeSync
/// (flushed + discarded; S2C sequence 2, S2C keystream word 1, cached).
/// </summary>
private static (AceSessionModel Model, TestAcClient Client, VirtualClock Clock)
CreateNegotiatedModel()
{
var clock = new VirtualClock();
var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie);
model.LoginRequestReceived += model.SendConnectRequest;
model.Receive(BuildLoginRequest());
model.Update();
model.TakePendingDatagrams(); // discard the ConnectRequest (sequence 0)
model.Receive(BuildConnectResponse());
model.Update();
model.TakePendingDatagrams(); // discard the immediate first TimeSync (sequence 2)
return (model, new TestAcClient(ClientSeed), clock);
}
private static byte[] BuildLoginRequest()
{
byte[] payload = LoginRequest.Build("testaccount", "testpassword", 1234);
return PacketCodec.Encode(
new PacketHeader { Flags = PacketHeaderFlags.LoginRequest },
payload,
outboundIsaac: null);
}
private static byte[] BuildConnectResponse()
{
byte[] body = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(body, Cookie);
return PacketCodec.Encode(
new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse },
body,
outboundIsaac: null);
}
/// <summary>Sequential post-handshake game-message packets: sequences 2..,
/// fragment sequences 1.., one keystream word each, marker = index + 2.</summary>
private static byte[][] BuildSequentialPackets(TestAcClient client, int count) =>
Enumerable.Range(0, count)
.Select(i => client.BuildGameMessagePacket(MakeMessage((byte)(i + 2))))
.ToArray();
/// <summary>An 8-byte message body whose first byte is a test marker.</summary>
private static byte[] MakeMessage(byte marker) =>
new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 };
private static byte MessageMarker(byte[] messageBody) => messageBody[0];
private static byte[] Markers(AceSessionModel model) =>
model.DispatchedMessages.Select(MessageMarker).ToArray();
private static PacketHeader Head(byte[] datagram) => PacketHeader.Unpack(datagram);
private static List<byte[]> OfExactFlags(
IEnumerable<byte[]> datagrams,
PacketHeaderFlags flags) =>
datagrams.Where(d => Head(d).Flags == flags).ToList();
private static uint[] NakIds(byte[] nakDatagram)
{
PacketCodec.PacketDecodeResult decoded =
PacketCodec.TryDecode(nakDatagram, inboundIsaac: null);
Assert.True(decoded.IsOk, decoded.Error.ToString());
return decoded.Packet!.Optional.RetransmitRequests.ToArray();
}
/// <summary>RejectRetransmit body: u32 count + ids (PacketRejectRetransmit.cs:7-17).</summary>
private static uint[] RejectIds(byte[] rejectDatagram)
{
ReadOnlySpan<byte> body = rejectDatagram.AsSpan(PacketHeader.Size);
uint count = BinaryPrimitives.ReadUInt32LittleEndian(body);
var ids = new uint[count];
for (int i = 0; i < ids.Length; i++)
ids[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4 + i * 4));
return ids;
}
/// <summary>
/// Recover the ISAAC word from an encrypted datagram's checksum:
/// key = (checksum headerHash) ^ payloadHash (ClientPacket.cs:142).
/// Returns 0 for cleartext packets.
/// </summary>
private static uint ExtractIsaacKey(byte[] datagram)
{
PacketHeader header = PacketHeader.Unpack(datagram);
ReadOnlySpan<byte> body = datagram.AsSpan(PacketHeader.Size, header.DataSize);
var optional = new PacketHeaderOptional();
int consumed = optional.Parse(body, header.Flags);
Assert.True(consumed >= 0);
uint payloadHash = optional.CalculateHash32();
if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
{
ReadOnlySpan<byte> remaining = body.Slice(consumed);
while (!remaining.IsEmpty)
{
(MessageFragment? fragment, int fragmentBytes) =
MessageFragment.TryParse(remaining);
Assert.NotNull(fragment);
payloadHash += PacketCodec.CalculateFragmentHash32(fragment!.Value);
remaining = remaining.Slice(fragmentBytes);
}
}
return (header.Checksum - header.CalculateHeaderHash32()) ^ payloadHash;
}
private static IsaacRandom MakeIsaac(uint seed)
{
Span<byte> seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
return new IsaacRandom(seedBytes);
}
/// <summary>
/// The client half of the conversation: builds wire-true packets with the
/// same primitives WorldSession uses (GameMessageFragment +
/// PacketCodec.Encode), drawing exactly one outbound keystream word per
/// encrypted encode — so "loss" is simulated by building in order and
/// simply not delivering.
/// </summary>
private sealed class TestAcClient
{
private readonly IsaacRandom _outboundIsaac;
/// <summary>WorldSession.cs:868 — the post-handshake reliable stream starts at 2.</summary>
public uint PacketSequence = 2;
/// <summary>WorldSession.cs:680 — fragment sequence starts at 1.</summary>
public uint FragmentSequence = 1;
public TestAcClient(uint clientSeed) => _outboundIsaac = MakeIsaac(clientSeed);
public byte[] BuildGameMessagePacket(byte[] messageBody) =>
BuildGameMessagePacket(PacketSequence++, FragmentSequence++, messageBody);
public byte[] BuildGameMessagePacket(
uint packetSequence,
uint fragmentSequence,
byte[] messageBody)
{
byte[] fragment = GameMessageFragment.Serialize(
GameMessageFragment.BuildSingleFragment(
fragmentSequence,
GameMessageGroup.UIQueue,
messageBody));
var header = new PacketHeader
{
Sequence = packetSequence,
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Id = (ushort)ClientId,
};
return PacketCodec.Encode(header, fragment, _outboundIsaac);
}
public byte[] BuildCleartextAck(uint headerSequence, uint ackValue)
{
byte[] body = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, ackValue);
return PacketCodec.Encode(
new PacketHeader
{
Sequence = headerSequence,
Flags = PacketHeaderFlags.AckSequence,
Id = (ushort)ClientId,
},
body,
outboundIsaac: null);
}
public byte[] BuildCleartextNak(uint headerSequence, params uint[] ids)
{
byte[] body = new byte[4 + ids.Length * 4];
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)ids.Length);
for (int i = 0; i < ids.Length; i++)
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + i * 4), ids[i]);
return PacketCodec.Encode(
new PacketHeader
{
Sequence = headerSequence,
Flags = PacketHeaderFlags.RequestRetransmit,
Id = (ushort)ClientId,
},
body,
outboundIsaac: null);
}
public byte[] BuildCleartextEchoRequest(uint headerSequence, float clientTime)
{
byte[] body = new byte[4];
BinaryPrimitives.WriteSingleLittleEndian(body, clientTime);
return PacketCodec.Encode(
new PacketHeader
{
Sequence = headerSequence,
Flags = PacketHeaderFlags.EchoRequest,
Id = (ushort)ClientId,
},
body,
outboundIsaac: null);
}
}
}

View file

@ -0,0 +1,219 @@
using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// An <see cref="IWorldSessionTransport"/> that binds a REAL
/// <see cref="WorldSession"/> to an <see cref="AceSessionModel"/> through a
/// <see cref="LossyLink"/> — no sockets anywhere. Outbound sends run through
/// the link into the model; the model's emitted datagrams run through the
/// link into the queue that <c>Receive</c>/<c>ReceiveAsync</c> serve.
///
/// <para>
/// The handshake is scripted against the model's events so a test can run a
/// genuine <c>Connect()</c> / <c>EnterWorld()</c> / <c>Tick()</c> /
/// <c>Dispose()</c> lifecycle:
/// <list type="number">
/// <item>LoginRequest → the model answers with a ConnectRequest carrying
/// the 32-byte optional (server time, cookie, client id, both ISAAC
/// seeds) — the same layout the existing negotiation fixture
/// (<c>WorldSessionNegotiationShutdownTests.BuildConnectRequest</c>)
/// pins.</item>
/// <item>ConnectResponse (cookie match) → the model enqueues a
/// CharacterList (0xF658) with one selectable character.</item>
/// <item>CharacterEnterWorldRequest (0xF7C8) → ServerReady (0xF7DF).</item>
/// <item>CharacterLogOff (0xF653 request) → the opcode-only 0xF653
/// confirmation, so <c>Dispose()</c> completes its retail graceful
/// logout instead of burning the 35 s confirmation timeout.</item>
/// </list>
/// Because the model seeds its C2S verifier and S2C keystream from the same
/// seeds it hands out in the ConnectRequest, post-handshake encrypted
/// traffic verifies in both directions.
/// </para>
///
/// <para>
/// Thread-safety: the model is single-threaded, so every model interaction
/// happens under one lock. <c>ReceiveAsync</c> (the session's background
/// receive loop) waits on a semaphore counting queued deliverables.
/// </para>
/// </summary>
internal sealed class FakeAceTransport : IWorldSessionTransport
{
public const uint DefaultClientSeed = 0x2B6D6F87u;
public const uint DefaultServerSeed = 0x9A3C51E4u;
public const uint DefaultClientId = 0x1234u;
public const ulong DefaultCookie = 0xFEEDFACECAFEBABEUL;
public const uint DefaultCharacterId = 0x50000001u;
public const string DefaultCharacterName = "+Acdream";
public const string DefaultAccountName = "testaccount";
private readonly object _gate = new();
private readonly SemaphoreSlim _deliverable = new(0);
private readonly Queue<byte[]> _toClient = new();
private readonly IPEndPoint _serverEndpoint = new(IPAddress.Loopback, 9000);
public VirtualClock Clock { get; }
public LossyLink Link { get; }
public AceSessionModel Model { get; }
public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
{
Clock = clock ?? new VirtualClock();
Link = link ?? new LossyLink();
Model = new AceSessionModel(
Clock,
DefaultClientSeed,
DefaultServerSeed,
DefaultClientId,
DefaultCookie);
Model.LoginRequestReceived += () => Model.SendConnectRequest();
Model.ConnectResponseAccepted += () =>
Model.EnqueueGameMessage(BuildCharacterListBody(), GameMessageGroup.UIQueue);
Model.MessageDispatched += OnClientMessage;
}
private void OnClientMessage(byte[] body)
{
if (body.Length < 4)
return;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
switch (opcode)
{
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
// WorldSession.EnterWorld blocks on this opcode.
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
break;
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
// ACE echoes the opcode-only confirmation; WorldSession.Dispose
// waits for it before sending the transport Disconnect.
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(CharacterLogOff.Opcode), GameMessageGroup.UIQueue);
break;
}
}
// ---- IWorldSessionTransport ----
public void Send(ReadOnlySpan<byte> datagram) => SendCore(datagram);
// WorldSession sends the ConnectResponse to port+1; the double serves
// both listeners from one model, like ACE's single-process server.
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) => SendCore(datagram);
private void SendCore(ReadOnlySpan<byte> datagram)
{
lock (_gate)
{
foreach (byte[] delivered in Link.Transmit(LinkDirection.ClientToServer, datagram))
Model.Receive(delivered);
PumpServerLocked();
}
}
/// <summary>
/// Run one server frame (model Update + S2C link delivery) without any
/// client traffic — the hook tests use after advancing the clock or
/// enqueuing server-side messages.
/// </summary>
public void PumpServer()
{
lock (_gate)
{
PumpServerLocked();
}
}
private void PumpServerLocked()
{
Model.Update();
foreach (byte[] outbound in Model.TakePendingDatagrams())
{
foreach (byte[] delivered in Link.Transmit(LinkDirection.ServerToClient, outbound))
{
_toClient.Enqueue(delivered);
_deliverable.Release();
}
}
}
public int Receive(Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
{
lock (_gate)
{
PumpServerLocked();
}
if (timeout < TimeSpan.Zero)
timeout = TimeSpan.Zero;
if (!_deliverable.Wait(timeout))
{
from = null;
return -1; // NetClient.Receive's timeout contract
}
from = _serverEndpoint;
lock (_gate)
{
byte[] datagram = _toClient.Dequeue();
datagram.CopyTo(destination);
return datagram.Length;
}
}
public async ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken)
{
await _deliverable.WaitAsync(cancellationToken).ConfigureAwait(false);
lock (_gate)
{
byte[] datagram = _toClient.Dequeue();
datagram.CopyTo(destination);
return new NetReceiveResult(datagram.Length, _serverEndpoint);
}
}
public void Dispose()
{
// WorldSession disposes the transport only after cancelling and
// joining its receive task, so no waiter can be parked on the
// semaphore here. SemaphoreSlim without AvailableWaitHandle holds no
// unmanaged state — deliberately left to the GC to keep a hypothetical
// late waiter from hitting ObjectDisposedException.
}
// ---- scripted server content ----
/// <summary>
/// Minimal CharacterList (0xF658) matching <c>CharacterList.Parse</c>:
/// status, active characters, deleted characters, slot count, account,
/// turbine chat, ToD flag.
/// </summary>
private static byte[] BuildCharacterListBody()
{
var writer = new PacketWriter(96);
writer.WriteUInt32(CharacterList.Opcode);
writer.WriteUInt32(0); // status
writer.WriteUInt32(1); // active count
writer.WriteUInt32(DefaultCharacterId);
writer.WriteString16L(DefaultCharacterName);
writer.WriteUInt32(0); // secondsGreyedOut
writer.WriteUInt32(0); // deleted count
writer.WriteUInt32(11); // slot count
writer.WriteString16L(DefaultAccountName);
writer.WriteUInt32(1); // useTurbineChat
writer.WriteUInt32(1); // hasThroneOfDestiny
return writer.ToArray();
}
private static byte[] BuildOpcodeOnlyBody(uint opcode)
{
byte[] body = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, opcode);
return body;
}
}

View file

@ -0,0 +1,179 @@
using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// Tests of the N0 harness plumbing: the deterministic <see cref="LossyLink"/>
/// fault injector and the <see cref="FakeAceTransport"/> that binds a REAL
/// <see cref="WorldSession"/> to the <see cref="AceSessionModel"/> with no
/// sockets anywhere.
/// </summary>
public sealed class FakeAceTransportTests
{
// ---- LossyLink ----
[Fact]
public void LossyLink_DropNextAndDropAt_DropDeterministically()
{
var link = new LossyLink();
link.DropNext(LinkDirection.ClientToServer);
link.DropAt(LinkDirection.ClientToServer, 2);
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // index 0: DropNext
Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 })); // index 1
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 })); // index 2: DropAt
Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 4 })); // index 3
Assert.Equal(4, link.TransmitCount(LinkDirection.ClientToServer));
Assert.Equal(2, link.DroppedCount(LinkDirection.ClientToServer));
Assert.Equal(2, link.DeliveredCount(LinkDirection.ClientToServer));
// Directions are independent.
Assert.Equal(0, link.TransmitCount(LinkDirection.ServerToClient));
}
[Fact]
public void LossyLink_PredicateDrop_IsPersistent()
{
var link = new LossyLink();
link.Drop(LinkDirection.ServerToClient, (_, datagram) => datagram[0] == 0xAA);
Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA }));
Assert.Single(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xBB }));
Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA }));
Assert.Equal(2, link.DroppedCount(LinkDirection.ServerToClient));
}
[Fact]
public void LossyLink_Reorder_SwapsAdjacentDatagrams()
{
var link = new LossyLink();
link.Reorder(LinkDirection.ClientToServer);
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // held
IReadOnlyList<byte[]> delivered =
link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 });
Assert.Equal(2, delivered.Count);
Assert.Equal(2, delivered[0][0]); // the follower first
Assert.Equal(1, delivered[1][0]); // then the held one
// A held datagram with no follower can be force-released.
link.Reorder(LinkDirection.ClientToServer);
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 }));
IReadOnlyList<byte[]> drained = link.DrainHeld(LinkDirection.ClientToServer);
Assert.Equal(3, Assert.Single(drained)[0]);
}
[Fact]
public void LossyLink_SeededRandomLoss_IsDeterministic()
{
var first = new LossyLink();
var second = new LossyLink();
first.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42);
second.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42);
for (int i = 0; i < 100; i++)
{
byte[] datagram = { (byte)i };
Assert.Equal(
first.Transmit(LinkDirection.ClientToServer, datagram).Count,
second.Transmit(LinkDirection.ClientToServer, datagram).Count);
}
// At 50% over 100 datagrams both outcomes occur.
Assert.True(first.DroppedCount(LinkDirection.ClientToServer) > 0);
Assert.True(first.DeliveredCount(LinkDirection.ClientToServer) > 0);
}
// ---- FakeAceTransport end-to-end ----
/// <summary>
/// The N0 goal made concrete: a genuine <c>Connect()</c> /
/// <c>EnterWorld()</c> / <c>Tick()</c> / <c>Dispose()</c> lifecycle runs
/// against the ACE-behaviour model with zero sockets — including both
/// ISAAC streams staying aligned end-to-end and retail's graceful-logout
/// order at teardown.
/// </summary>
[Fact]
public void RealWorldSession_HandshakeEnterWorldTickAndGracefulLogout_NoSockets()
{
var transport = new FakeAceTransport();
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
try
{
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState);
Assert.NotNull(session.Characters);
CharacterList.Character character = Assert.Single(session.Characters!.Characters);
Assert.Equal(FakeAceTransport.DefaultCharacterName, character.Name);
Assert.Equal(FakeAceTransport.DefaultAccountName, session.Characters.AccountName);
var messages = new List<string>();
session.ServerMessageReceived += m => messages.Add(m.Message);
session.EnterWorld(0, TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
// A world message flows model → link → async receive loop →
// Tick() → typed event.
transport.Model.EnqueueGameMessage(
BuildServerMessage("hello acdream"),
GameMessageGroup.UIQueue);
transport.PumpServer();
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
while (messages.Count == 0 && DateTime.UtcNow < deadline)
{
session.Tick();
Thread.Sleep(5);
}
Assert.Equal("hello acdream", Assert.Single(messages));
// The model saw the genuine ordered client stream, and neither
// direction desynced its ISAAC keystream.
Assert.Equal(
new[]
{
CharacterEnterWorld.EnterWorldRequestOpcode,
CharacterEnterWorld.EnterWorldOpcode,
},
transport.Model.DispatchedMessages.Select(ReadOpcode).ToArray());
Assert.Equal(0, transport.Model.CrcDropCount);
Assert.Equal(0, transport.Model.DuplicateDropCount);
Assert.Equal(256, transport.Model.Crypto.Headroom);
}
finally
{
session.Dispose();
}
// Dispose ran retail's graceful order — 0xF653 request, the model's
// scripted confirmation, then the transport Disconnect that
// terminates the model exactly like ACE's session teardown.
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
Assert.True(transport.Model.IsTerminated);
Assert.Equal(
AceTerminationReason.PacketHeaderDisconnect,
transport.Model.TerminationReason);
Assert.Equal(
CharacterLogOff.Opcode,
ReadOpcode(transport.Model.DispatchedMessages[^1]));
Assert.Equal(0, transport.Model.CrcDropCount);
}
private static uint ReadOpcode(byte[] messageBody) =>
BinaryPrimitives.ReadUInt32LittleEndian(messageBody);
private static byte[] BuildServerMessage(string text)
{
var writer = new PacketWriter(64);
writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0
writer.WriteString16L(text);
writer.WriteUInt32(1); // ChatMessageType
return writer.ToArray();
}
}

View file

@ -0,0 +1,168 @@
namespace AcDream.Core.Net.Tests.Transport;
internal enum LinkDirection
{
ClientToServer,
ServerToClient,
}
/// <summary>
/// Deterministic datagram fault injector between two endpoints. Pure data
/// structure — no sockets, no threads, no wall clock. Callers push each
/// datagram through <see cref="Transmit"/> and deliver whatever comes back,
/// in order.
///
/// <para>
/// Fault evaluation order per datagram (first match wins):
/// scheduled per-index drops (<see cref="DropAt"/>) → one-shot drop budget
/// (<see cref="DropNext"/>) → persistent predicates (<see cref="Drop"/>) →
/// seeded random loss (<see cref="RandomLoss"/>). A surviving datagram is
/// then subject to an armed <see cref="Reorder"/>: the next survivor is held
/// back and delivered immediately AFTER the survivor that follows it
/// (adjacent swap). Datagram bytes are copied on entry, so callers may pass
/// stack-allocated spans.
/// </para>
/// </summary>
internal sealed class LossyLink
{
private sealed class DirectionState
{
public int TransmitIndex;
public int PendingDropCount;
public readonly HashSet<int> DropIndices = new();
public readonly List<Func<int, byte[], bool>> DropPredicates = new();
public Random? LossRandom;
public double LossProbability;
public bool ReorderArmed;
public byte[]? Held;
public int Dropped;
public int Delivered;
}
private readonly DirectionState _clientToServer = new();
private readonly DirectionState _serverToClient = new();
private DirectionState State(LinkDirection direction) =>
direction == LinkDirection.ClientToServer ? _clientToServer : _serverToClient;
/// <summary>Drop the next <paramref name="count"/> datagrams in <paramref name="direction"/>.</summary>
public void DropNext(LinkDirection direction, int count = 1)
{
ArgumentOutOfRangeException.ThrowIfNegative(count);
State(direction).PendingDropCount += count;
}
/// <summary>Drop the datagram with the given per-direction transmit index (0-based).</summary>
public void DropAt(LinkDirection direction, int transmitIndex) =>
State(direction).DropIndices.Add(transmitIndex);
/// <summary>
/// Drop every datagram matching <paramref name="predicate"/> (persistent;
/// receives the per-direction transmit index and the datagram bytes).
/// </summary>
public void Drop(LinkDirection direction, Func<int, byte[], bool> predicate) =>
State(direction).DropPredicates.Add(predicate);
/// <summary>
/// Swap the next two surviving datagrams: the next survivor is held and
/// released right after the survivor that follows it.
/// </summary>
public void Reorder(LinkDirection direction) =>
State(direction).ReorderArmed = true;
/// <summary>
/// Enable seeded random loss: each surviving datagram is dropped with
/// <paramref name="probability"/> using <c>Random(seed)</c> — fully
/// deterministic for a given seed + transmit sequence.
/// </summary>
public void RandomLoss(LinkDirection direction, double probability, int seed)
{
ArgumentOutOfRangeException.ThrowIfNegative(probability);
ArgumentOutOfRangeException.ThrowIfGreaterThan(probability, 1.0);
DirectionState state = State(direction);
state.LossProbability = probability;
state.LossRandom = new Random(seed);
}
public int TransmitCount(LinkDirection direction) => State(direction).TransmitIndex;
public int DroppedCount(LinkDirection direction) => State(direction).Dropped;
public int DeliveredCount(LinkDirection direction) => State(direction).Delivered;
/// <summary>
/// Push one datagram through the link. Returns the datagrams to deliver
/// now, in order (0, 1, or 2 entries — 2 when a held reordered datagram
/// is released).
/// </summary>
public IReadOnlyList<byte[]> Transmit(LinkDirection direction, ReadOnlySpan<byte> datagram)
{
DirectionState state = State(direction);
int index = state.TransmitIndex++;
byte[] copy = datagram.ToArray();
bool drop = state.DropIndices.Remove(index);
if (!drop && state.PendingDropCount > 0)
{
state.PendingDropCount--;
drop = true;
}
if (!drop)
{
foreach (Func<int, byte[], bool> predicate in state.DropPredicates)
{
if (predicate(index, copy))
{
drop = true;
break;
}
}
}
if (!drop
&& state.LossRandom is not null
&& state.LossRandom.NextDouble() < state.LossProbability)
{
drop = true;
}
if (drop)
{
state.Dropped++;
return Array.Empty<byte[]>();
}
if (state.ReorderArmed)
{
state.ReorderArmed = false;
state.Held = copy;
return Array.Empty<byte[]>();
}
if (state.Held is not null)
{
byte[] held = state.Held;
state.Held = null;
state.Delivered += 2;
return new[] { copy, held };
}
state.Delivered++;
return new[] { copy };
}
/// <summary>
/// Force-release a datagram held by <see cref="Reorder"/> that nothing
/// followed (it would otherwise be stuck forever). Returns the held
/// datagram or an empty list.
/// </summary>
public IReadOnlyList<byte[]> DrainHeld(LinkDirection direction)
{
DirectionState state = State(direction);
if (state.Held is null)
return Array.Empty<byte[]>();
byte[] held = state.Held;
state.Held = null;
state.Delivered++;
return new[] { held };
}
}

View file

@ -0,0 +1,54 @@
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// Deterministic monotonic time source for transport tests. Exposes the same
/// (timestamp, frequency) shape as <c>System.Diagnostics.Stopwatch</c>
/// (<c>GetTimestamp()</c> + <c>Frequency</c>) so slice N1 can inject it
/// behind the production <c>TransportClock</c> without adapting call sites.
/// Time only moves when a test calls <see cref="Advance"/>.
///
/// <para>
/// Deliberately dependency-free (System only) and NOT tied to the machine's
/// <c>Stopwatch.Frequency</c>: a fixed 100 ns tick makes every gate
/// computation reproducible across platforms.
/// </para>
/// </summary>
internal sealed class VirtualClock
{
/// <summary>
/// Fixed tick rate: 100 ns ticks (10,000,000 per second), equal to
/// <see cref="TimeSpan.TicksPerSecond"/> so <see cref="TimeSpan"/>
/// arithmetic maps 1:1 onto clock ticks.
/// </summary>
public const long TicksPerSecond = TimeSpan.TicksPerSecond;
private long _timestamp;
public VirtualClock(long startTimestamp = 0) => _timestamp = startTimestamp;
/// <summary><c>Stopwatch.Frequency</c> equivalent.</summary>
public long Frequency => TicksPerSecond;
/// <summary><c>Stopwatch.GetTimestamp()</c> equivalent.</summary>
public long GetTimestamp() => _timestamp;
/// <summary>
/// Seconds since the clock's epoch as a double — the shape of the
/// retail/ACE PortalYearTicks-style wall values written into TimeSync
/// payloads and the packet header's 16-bit <c>Time</c> field.
/// </summary>
public double Seconds => (double)_timestamp / TicksPerSecond;
/// <summary>Move time forward. The clock is monotonic — negative deltas throw.</summary>
public void Advance(TimeSpan delta)
{
if (delta < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(delta),
"the clock is monotonic — it cannot go backwards");
}
_timestamp += delta.Ticks;
}
}