acdream/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs
Erik e395861053 test(net): N0 fix-up - CheckState gate, bundle coalescing, two-phase terminate
Addresses the N0 review findings against commit 7e9134b4. Test-only: no
production code changes.

F1 (blocking) - model Session.CheckState (Session.cs:93-110). A three-value
AceSessionState (AuthLoginRequest -> AuthConnectResponse -> AuthConnected)
advances on SendConnectRequest (AuthenticationHandler.cs:127, :232) and on the
accepted ConnectResponse (NetworkManager.cs:77). CheckState runs as the first
statement of Receive after TryParse - ahead of the ConnectResponse route and
ahead of VerifyCRC - so a LoginRequest out of state, a replayed
ConnectResponse, or any of AckSequence|TimeSync|EchoRequest|Flow during
AuthLoginRequest is dropped at zero keystream cost (ACE's PacketHeader.HasFlag
is ANY-of, PacketHeader.cs:70). New StateDropCount counter.

F2 - implement SendBundle faithfully (NetworkSession.cs:808-919). One
NetworkBundle per GameMessageGroup (NetworkBundle.cs:6-63), swapped out and
sent in ascending group order; the InvalidQueue bundle carries the ack /
TimeSync / EchoResponse optional headers. As many same-bundle fragments as fit
the 464-byte body budget now travel in ONE packet - one sequence, one keystream
word - and a message whose remaining data fills a packet splits across packets
with Count>1 fragments (:846-854, :874-888) via a port of ACE's server-side
MessageFragment (MessageFragment.cs:10-103). The old "one packet per message"
shortcut and its incorrect rationale are gone.

F3 - model the two-phase termination. Terminate arms PendingTermination with
the 2 s window (Session.cs:281-298, SessionTerminationDetails.cs:12); inbound
and outbound keep running through it (Session.cs:124-133), then the pump
completes the session work and releases the network resources
(NetworkManager.cs:366-369 -> Session.cs:300-334 -> NetworkSession.cs:958-974).
IsTerminated now means "termination armed"; IsReleased is the point of no
return.

F4 - port ACE's MessageBuffer exactly (MessageBuffer.cs:7-54): a List, not an
index-addressed array. An assembled stream under 4 bytes returns null and is
dropped WITHOUT advancing the fragment gate (:49-50 + NetworkSession.cs:504-506
removing the buffer either way), and a later fragment claiming a larger
Count/Index for the same sequence completes the message instead of throwing.

F5 - the C2S parse path now characterizes ACE: fragment parsing uses ACE's
complete validation (16 <= Size <= 464, ClientPacketFragment.cs:12-24) with no
Count==0 / Index>=Count rejection and with ReadBytes' short-read tolerance,
instead of inheriting acdream's stricter production layout check. The one
remaining strictness we inherit - the 1024-id cap on retransmit lists - is
documented as unreachable (ACE reads into a 1024-byte buffer, so a C2S datagram
can carry at most 250 ids).

F6 - class doc now states that C2S CRC verification reuses acdream's own
PacketHeaderOptional hashing, so the double is NOT an independent oracle on
optional-header wire layout, and names the two known asymmetries (ACE has no
inbound ConnectRequest parse; ACE hashes-but-does-not-advance on
LoginRequest / WorldLoginRequest / ConnectResponse).

F7 - hardened three weak tests: the NAK rate limit is probed at 0.9 s and at
exactly 1.0 s (both closed) before 1.1 s opens it; the session timeout is
probed at exactly 60 s after fixing the model's `>` to ACE's `>=`
(Session.cs:140); the cache prune pins that an entry exactly 120 s old survives
(:258 is strictly greater).

F9 - campaign doc section 9 ledger: N0 row marked complete.

Nine new tests; 687 Core.Net tests green in Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:42:36 +02:00

1140 lines
53 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;
// =====================================================================
// Session.CheckState — the pre-CRC inbound gate (Session.cs:93-113)
// =====================================================================
[Fact]
public void CheckState_DropsControlPacketsBeforeNegotiation_ThenConsumesThemAfter()
{
var clock = new VirtualClock();
var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie);
model.LoginRequestReceived += model.SendConnectRequest;
var client = new TestAcClient(ClientSeed);
Assert.Equal(AceSessionState.AuthLoginRequest, model.State);
// Built now, while the client's outbound wheel is at word 1 — so the
// SAME bytes must still verify after the handshake if (and only if)
// the pre-handshake delivery really cost no keystream.
byte[] cleartextAck = client.BuildCleartextAck(headerSequence: 2, ackValue: 1);
byte[] encryptedAck = client.BuildEncryptedAck(headerSequence: 2, ackValue: 1);
uint keyBeforeGate = model.Crypto.CurrentKey;
// Session.cs:101-102 — ANY of AckSequence|TimeSync|EchoRequest|Flow
// while State == AuthLoginRequest is dropped by Session.ProcessPacket
// BEFORE NetworkSession.ProcessPacket runs, so it never reaches
// ClientPacket.VerifyCRC: no keystream word, no watermark move, no
// CRC counter.
model.Receive(cleartextAck);
model.Receive(encryptedAck);
Assert.Equal(2, model.StateDropCount);
Assert.Equal(0, model.CrcDropCount);
Assert.Equal(0, model.DuplicateDropCount);
Assert.Equal(1u, model.LastReceivedPacketSequence);
Assert.Equal(keyBeforeGate, model.Crypto.CurrentKey);
Assert.Equal(256, model.Crypto.Headroom);
Assert.Equal(0, model.Crypto.OrphanCount);
// Negotiate: LoginRequest → ConnectRequest (AuthenticationHandler.cs:127,
// :232) → ConnectResponse (NetworkManager.cs:77).
model.Receive(BuildLoginRequest());
Assert.Equal(AceSessionState.AuthConnectResponse, model.State);
model.Update();
model.TakePendingDatagrams();
model.Receive(BuildConnectResponse());
Assert.Equal(AceSessionState.AuthConnected, model.State);
model.Update();
model.TakePendingDatagrams();
// The same cleartext ack now passes the gate. Flags are EXACTLY
// AckSequence so the watermark stays put (:474-476) and no key is
// involved.
model.Receive(cleartextAck);
Assert.Equal(2, model.StateDropCount);
Assert.Equal(0, model.CrcDropCount);
Assert.Equal(1u, model.LastReceivedPacketSequence);
Assert.Equal(keyBeforeGate, model.Crypto.CurrentKey);
// And the encrypted one is consumed normally: its ORIGINAL key (drawn
// before the handshake) is still the server's current key, proving the
// gate cost nothing. Its flags are not exactly AckSequence, so the
// watermark does advance.
model.Receive(encryptedAck);
Assert.Equal(0, model.CrcDropCount);
Assert.Equal(2u, model.LastReceivedPacketSequence);
Assert.NotEqual(keyBeforeGate, model.Crypto.CurrentKey);
Assert.Equal(256, model.Crypto.Headroom);
}
[Fact]
public void CheckState_DropsLoginRequestAndConnectResponseOutOfState()
{
(AceSessionModel model, _, _) = CreateNegotiatedModel();
Assert.Equal(AceSessionState.AuthConnected, model.State);
int loginRequests = 0;
int connectResponses = 0;
model.LoginRequestReceived += () => loginRequests++;
model.ConnectResponseAccepted += () => connectResponses++;
// Session.cs:95-96 — a LoginRequest after the handshake is dropped
// before the auth handler ever sees it.
model.Receive(BuildLoginRequest());
Assert.Equal(1, model.StateDropCount);
Assert.Equal(0, loginRequests);
// Session.cs:98-99 (and NetworkManager.cs:60-66, whose session lookup
// requires State == AuthConnectResponse) — a replayed ConnectResponse
// cannot re-run the handshake.
model.Receive(BuildConnectResponse());
Assert.Equal(2, model.StateDropCount);
Assert.Equal(0, connectResponses);
Assert.Equal(0, model.CrcDropCount);
Assert.Equal(0, model.DuplicateDropCount);
Assert.Equal(AceSessionState.AuthConnected, model.State);
}
// =====================================================================
// Inbound sequencing / crypto discipline
// =====================================================================
[Fact]
public void Nak_FiresOnlyAtDesiredPlusTwo_WithOneSecondRateLimit()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
byte[][] packets = BuildSequentialPackets(client, count: 7); // seq 2..8
// 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));
// Just under the limit: still closed.
clock.Advance(TimeSpan.FromSeconds(0.9));
model.Receive(packets[4]);
model.Update();
Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
// EXACTLY 1 s: ACE's comparison is strict (`> new TimeSpan(0, 0, 1)`,
// :359), so the boundary itself is still closed.
clock.Advance(TimeSpan.FromSeconds(0.1));
model.Receive(packets[5]);
model.Update();
Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
// Limiter reopens strictly after 1 s.
clock.Advance(TimeSpan.FromSeconds(0.1));
model.Receive(packets[6]);
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);
}
// =====================================================================
// Multi-fragment C2S reassembly — NetworkSession.ProcessFragment
// (:483-518) over ACE's MessageBuffer (MessageBuffer.cs:7-54)
// =====================================================================
[Fact]
public void SplitC2SMessage_StaysIncompleteUntilTheDroppedPacketIsRedelivered()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[] partA = { 0x11, 0x22, 0x33, 0x44 };
byte[] partB = { 0x55, 0x66, 0x77, 0x88 };
// One logical message split across two packets (fragment sequence 1,
// Count 2), then two ordinary follow-on messages. Built in send order
// so each draws its own outbound keystream word.
byte[] head = client.BuildFragmentPacket(2, fragmentSequence: 1, count: 2, index: 0, partA);
byte[] tail = client.BuildFragmentPacket(3, fragmentSequence: 1, count: 2, index: 1, partB);
byte[] third = client.BuildGameMessagePacket(4, 2, MakeMessage(4));
byte[] fourth = client.BuildGameMessagePacket(5, 3, MakeMessage(5));
model.Receive(head);
Assert.Equal(1, model.PartialFragmentBufferCount);
Assert.Empty(model.DispatchedMessages);
// `tail` is lost. Everything behind it stacks up at the packet level
// and ACE NAKs the hole; the half-built message just sits there.
model.Receive(third);
model.Receive(fourth);
model.Update();
byte[] nak = Assert.Single(
OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
Assert.Equal(new uint[] { 3u }, NakIds(nak));
Assert.Equal(1, model.PartialFragmentBufferCount);
Assert.Empty(model.DispatchedMessages);
Assert.Equal(0u, model.LastReceivedFragmentSequence);
// Redelivery completes the message and drains everything behind it.
model.Receive(tail);
Assert.Equal(3, model.DispatchedMessages.Count);
Assert.Equal(partA.Concat(partB).ToArray(), model.DispatchedMessages[0]);
Assert.Equal(new byte[] { 4, 5 }, model.DispatchedMessages.Skip(1).Select(MessageMarker).ToArray());
Assert.Equal(0, model.PartialFragmentBufferCount);
Assert.Equal(0, model.OutOfOrderPacketCount);
Assert.Equal(3u, model.LastReceivedFragmentSequence);
Assert.Equal(256, model.Crypto.Headroom); // the parked key was recovered
}
[Fact]
public void SplitC2SMessage_UnderFourBytes_IsDroppedAndStallsTheFragmentGate()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
// Two 1-byte fragments assemble to 2 bytes — under the 4-byte
// ClientMessage minimum, so MessageBuffer.TryGetMessage returns null
// (MessageBuffer.cs:49-50). ACE removes the buffer anyway (:504-506)
// and, because `message` is null, never advances the fragment gate.
model.Receive(client.BuildFragmentPacket(2, 1, 2, 0, new byte[] { 0xAA }));
model.Receive(client.BuildFragmentPacket(3, 1, 2, 1, new byte[] { 0xBB }));
Assert.Empty(model.DispatchedMessages);
Assert.Equal(0, model.PartialFragmentBufferCount);
Assert.Equal(0u, model.LastReceivedFragmentSequence);
Assert.Equal(0, model.CrcDropCount);
// The hole is permanent: every later message parks behind it forever
// (ACE bug-for-bug — only a fresh session recovers).
model.Receive(client.BuildGameMessagePacket(4, 2, MakeMessage(4)));
Assert.Empty(model.DispatchedMessages);
Assert.Equal(1, model.FragmentGateBufferCount);
Assert.Equal(4u, model.LastReceivedPacketSequence);
}
[Fact]
public void SplitC2SMessage_ToleratesLaterFragmentWithLargerCountAndIndex()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[] partA = { 0x11, 0x22, 0x33, 0x44 };
byte[] partB = { 0x55, 0x66, 0x77, 0x88 };
// ACE's MessageBuffer takes TotalFragments from the FIRST fragment it
// sees and completes on a COUNT match over a List (MessageBuffer.cs:9,
// :14, :22-31). A later fragment claiming Count 3 / Index 2 neither
// resizes the buffer nor lands out of range — it is simply the second
// entry, which completes the message.
model.Receive(client.BuildFragmentPacket(2, 1, count: 2, index: 0, partA));
model.Receive(client.BuildFragmentPacket(3, 1, count: 3, index: 2, partB));
byte[] assembled = Assert.Single(model.DispatchedMessages);
Assert.Equal(partA.Concat(partB).ToArray(), assembled); // sorted by Index (:38)
Assert.Equal(0, model.PartialFragmentBufferCount);
Assert.Equal(1u, model.LastReceivedFragmentSequence);
Assert.Equal(0, model.CrcDropCount);
}
[Fact]
public void ZeroCountFragment_IsAcceptedByTheParse_ThenSilentlyDropped()
{
(AceSessionModel model, TestAcClient client, _) = CreateNegotiatedModel();
byte[] zeroCount = client.BuildFragmentPacket(2, 1, count: 0, index: 0, MakeMessage(0x77));
// acdream's PRODUCTION parser refuses this shape
// (MessageFragment.TryParseLayout rejects Count == 0)...
Assert.Equal(
PacketCodec.DecodeError.InvalidFragment,
PacketCodec.TryDecode(zeroCount, inboundIsaac: null).Error);
// ...while ACE's ClientPacketFragment.Unpack (:10-23) only checks
// 16 ≤ Size ≤ 464, so the packet is parsed, CRC-verified and
// processed. ProcessFragment takes the split branch (Count != 1), the
// buffer is Complete at zero fragments, TryGetMessage returns null,
// and the whole thing evaporates — the packet still burns its
// keystream word and still advances the watermark.
model.Receive(zeroCount);
Assert.Equal(0, model.CrcDropCount);
Assert.Empty(model.DispatchedMessages);
Assert.Equal(0, model.PartialFragmentBufferCount);
Assert.Equal(0u, model.LastReceivedFragmentSequence);
Assert.Equal(2u, model.LastReceivedPacketSequence);
}
// =====================================================================
// Termination + timeout
// =====================================================================
[Fact]
public void Termination_KeepsRunningForTwoSeconds_ThenReleases()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
model.Receive(client.BuildGameMessagePacket(MakeMessage(2)));
Assert.Single(model.DispatchedMessages);
// Session.Terminate (Session.cs:281-298) only ARMS PendingTermination
// with a 2 s window (SessionTerminationDetails.cs:12).
model.Receive(TransportDisconnect.Build((ushort)ClientId, iteration: 1));
Assert.True(model.IsTerminated);
Assert.False(model.IsReleased);
Assert.Equal(AceTerminationPhase.Initialized, model.TerminationPhase);
Assert.Equal(AceTerminationReason.PacketHeaderDisconnect, model.TerminationReason);
// Phase 1 (Session.cs:126-131): inbound still processes...
clock.Advance(TimeSpan.FromSeconds(1));
model.Receive(client.BuildGameMessagePacket(MakeMessage(3)));
Assert.Equal(2, model.DispatchedMessages.Count);
// ...and Network.Update() still runs, so queued messages still leave
// ("boot messages may need sending", :129).
model.EnqueueGameMessage(MakeMessage(0xEE), GameMessageGroup.UIQueue);
model.Update();
Assert.False(model.IsReleased);
byte[] flushed = Assert.Single(model.TakePendingDatagrams());
Assert.Equal(
PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Head(flushed).Flags);
// Past TerminationEndTicks the pump completes the session work and
// DropSession releases the network resources (:130-131, :300-334).
clock.Advance(TimeSpan.FromSeconds(1.2));
model.Update();
Assert.True(model.IsReleased);
Assert.Equal(AceTerminationPhase.SessionWorkCompleted, model.TerminationPhase);
model.TakePendingDatagrams(); // that pump's due cumulative ack
// Released (NetworkSession.cs:271-272, :184-185): inbound and outbound
// are both no-ops.
model.Receive(client.BuildGameMessagePacket(MakeMessage(4)));
model.Update();
Assert.Equal(2, model.DispatchedMessages.Count);
Assert.Empty(model.TakePendingDatagrams());
}
[Fact]
public void SixtySecondTimeout_Terminates_AndCleartextNaksDoNotRefreshIt()
{
(AceSessionModel model, TestAcClient client, VirtualClock clock) = CreateNegotiatedModel();
model.Receive(client.BuildGameMessagePacket(MakeMessage(2))); // refresh → +60 s (:329-331)
long deadline = model.TimeoutDeadlineTimestamp;
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);
Assert.Equal(deadline, model.TimeoutDeadlineTimestamp);
model.Update();
Assert.False(model.IsTerminated);
// ACE compares `DateTime.UtcNow.Ticks >= Network.TimeoutTick`
// (Session.cs:140): the boundary itself kills the session.
clock.Advance(TimeSpan.FromSeconds(1));
Assert.Equal(deadline, clock.GetTimestamp());
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));
}
// =====================================================================
// Send side — retransmit, ack, echo, cache prune, bundling
// =====================================================================
[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 SendBundle_CoalescesSmallMessagesIntoOnePacket()
{
(AceSessionModel model, _, _) = CreateNegotiatedModel();
IsaacRandom shadow = MakeIsaac(ServerSeed);
shadow.Next(); // w1 — the negotiation TimeSync
uint w2 = shadow.Next();
uint w3 = shadow.Next();
// Three messages enqueued into the same bundle before one pump.
model.EnqueueGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
model.EnqueueGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue);
model.EnqueueGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue);
model.Update();
// NetworkSession.SendBundle (:828-903) packs everything that fits into
// ONE 464-byte packet: one sequence, one keystream word, three
// fragments carrying three consecutive fragment sequences (:821).
byte[] packet = Assert.Single(model.TakePendingDatagrams());
PacketHeader header = Head(packet);
Assert.Equal(3u, header.Sequence);
Assert.Equal(
PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
header.Flags);
Assert.Equal(w2, ExtractIsaacKey(packet));
MessageFragment[] fragments = FragmentsOf(packet);
Assert.Equal(3, fragments.Length);
Assert.Equal(new uint[] { 0u, 1u, 2u }, fragments.Select(f => f.Header.Sequence).ToArray());
Assert.All(fragments, f => Assert.Equal(1, (int)f.Header.Count));
Assert.All(fragments, f => Assert.Equal(0, (int)f.Header.Index));
Assert.All(fragments, f => Assert.Equal(GameMessageFragment.OutboundFragmentId, f.Header.Id));
Assert.Equal(
new byte[] { 0xA1, 0xB2, 0xC3 },
fragments.Select(f => f.Payload[0]).ToArray());
// Exactly one word was consumed by the whole bundle: the next packet
// takes the next one.
model.EnqueueGameMessage(MakeMessage(0xD4), GameMessageGroup.UIQueue);
model.Update();
byte[] next = Assert.Single(model.TakePendingDatagrams());
Assert.Equal(4u, Head(next).Sequence);
Assert.Equal(w3, ExtractIsaacKey(next));
Assert.Equal(3u, Assert.Single(FragmentsOf(next)).Header.Sequence);
}
[Fact]
public void SendBundle_SplitsLargeMessageAcrossPacketsWithCountGreaterThanOne()
{
(AceSessionModel model, _, _) = CreateNegotiatedModel();
IsaacRandom shadow = MakeIsaac(ServerSeed);
shadow.Next(); // w1 — the negotiation TimeSync
uint w2 = shadow.Next();
uint w3 = shadow.Next();
// 600 bytes > MaxFragmentDataSize (448) → Count = ceil(600/448) = 2
// (MessageFragment.cs:47).
byte[] large = MakeLargeMessage(600);
model.EnqueueGameMessage(large, GameMessageGroup.UIQueue);
model.Update();
List<byte[]> sent = model.TakePendingDatagrams();
Assert.Equal(2, sent.Count);
Assert.Equal(new uint[] { 3u, 4u }, sent.Select(d => Head(d).Sequence).ToArray());
Assert.Equal(w2, ExtractIsaacKey(sent[0]));
Assert.Equal(w3, ExtractIsaacKey(sent[1]));
// :846-854 — the head fills a packet alone; :874-880 — the tail rides
// the next one. Both carry the SAME fragment sequence and Count 2.
MessageFragment head = Assert.Single(FragmentsOf(sent[0]));
MessageFragment tail = Assert.Single(FragmentsOf(sent[1]));
Assert.Equal(2, (int)head.Header.Count);
Assert.Equal(0, (int)head.Header.Index);
Assert.Equal(MessageFragmentHeader.MaxFragmentDataSize, head.Payload.Length);
Assert.Equal(2, (int)tail.Header.Count);
Assert.Equal(1, (int)tail.Header.Index);
Assert.Equal(600 - MessageFragmentHeader.MaxFragmentDataSize, tail.Payload.Length);
Assert.Equal(head.Header.Sequence, tail.Header.Sequence);
Assert.Equal(large, head.Payload.Concat(tail.Payload).ToArray());
}
[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 with periodic client packets (each refreshes
// the 60 s deadline).
clock.Advance(TimeSpan.FromSeconds(50));
model.Receive(client.BuildGameMessagePacket(MakeMessage(2)));
clock.Advance(TimeSpan.FromSeconds(50));
model.Receive(client.BuildGameMessagePacket(MakeMessage(3)));
model.Update(); // t = 100 s: prune runs, the seq-2 entry is well inside
Assert.Contains(2u, model.CachedPacketSequences);
// The retention test is STRICTLY greater than 120 (:258), so at
// exactly 120 s the entry survives.
clock.Advance(TimeSpan.FromSeconds(20));
model.Receive(client.BuildGameMessagePacket(MakeMessage(4)));
model.Update();
Assert.Contains(2u, model.CachedPacketSequences);
// The next prune cannot run until the 5 s prune interval elapses
// (:187-188, :67), so the removal probe lands at 125.1 s.
clock.Advance(TimeSpan.FromSeconds(5.1));
model.Receive(client.BuildGameMessagePacket(MakeMessage(5)));
model.Update();
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(6, 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 and moves the state to AuthConnectResponse) →
/// 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 };
/// <summary>A message body too large for one fragment, with recognizable content.</summary>
private static byte[] MakeLargeMessage(int length)
{
byte[] body = new byte[length];
for (int i = 0; i < length; i++)
body[i] = (byte)(i * 7 + 3);
return body;
}
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();
/// <summary>Every fragment carried by a datagram, in wire order.</summary>
private static MessageFragment[] FragmentsOf(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);
var fragments = new List<MessageFragment>();
ReadOnlySpan<byte> remaining = body.Slice(consumed);
while (!remaining.IsEmpty)
{
(MessageFragment? fragment, int fragmentBytes) = MessageFragment.TryParse(remaining);
Assert.NotNull(fragment);
fragments.Add(fragment!.Value);
remaining = remaining.Slice(fragmentBytes);
}
return fragments.ToArray();
}
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);
}
/// <summary>
/// One packet carrying one arbitrarily-shaped fragment. Hand-rolled
/// (rather than <see cref="PacketCodec.Encode"/>) because acdream's
/// production encoder refuses shapes ACE happily accepts — notably
/// <c>Count == 0</c> — and the double's parse path exists to
/// characterize ACE. The checksum arithmetic mirrors
/// PacketCodec.FinalizeInPlace exactly.
/// </summary>
public byte[] BuildFragmentPacket(
uint packetSequence,
uint fragmentSequence,
ushort count,
ushort index,
byte[] payload)
{
var fragmentHeader = new MessageFragmentHeader
{
Sequence = fragmentSequence,
Id = GameMessageFragment.OutboundFragmentId,
Count = count,
TotalSize = (ushort)(MessageFragmentHeader.Size + payload.Length),
Index = index,
Queue = (ushort)GameMessageGroup.UIQueue,
};
int bodyLength = MessageFragmentHeader.Size + payload.Length;
byte[] datagram = new byte[PacketHeader.Size + bodyLength];
fragmentHeader.Pack(datagram.AsSpan(PacketHeader.Size));
payload.CopyTo(datagram.AsSpan(PacketHeader.Size + MessageFragmentHeader.Size));
var header = new PacketHeader
{
Sequence = packetSequence,
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Id = (ushort)ClientId,
DataSize = (ushort)bodyLength,
};
uint payloadHash = PacketCodec.CalculateFragmentHash32(
new MessageFragment(fragmentHeader, payload));
header.Checksum =
header.CalculateHeaderHash32() + (_outboundIsaac.Next() ^ payloadHash);
header.Pack(datagram);
return datagram;
}
public byte[] BuildCleartextAck(uint headerSequence, uint ackValue) =>
BuildAck(headerSequence, ackValue, encrypted: false);
/// <summary>
/// An ack whose flags are AckSequence|EncryptedChecksum — NOT the exact
/// AckSequence value, so it is a normal sequenced packet that consumes a
/// keystream word and advances ACE's watermark (:474-476).
/// </summary>
public byte[] BuildEncryptedAck(uint headerSequence, uint ackValue) =>
BuildAck(headerSequence, ackValue, encrypted: true);
private byte[] BuildAck(uint headerSequence, uint ackValue, bool encrypted)
{
byte[] body = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, ackValue);
return PacketCodec.Encode(
new PacketHeader
{
Sequence = headerSequence,
Flags = encrypted
? PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum
: PacketHeaderFlags.AckSequence,
Id = (ushort)ClientId,
},
body,
encrypted ? _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);
}
}
}