acdream/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs
Erik 7e9134b4d1 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>
2026-07-29 11:07:13 +02:00

668 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System.Buffers.Binary;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// 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);
}
}
}