Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.
New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
`intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
@ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
@ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
@ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
store, the wrap-safe sorted dedup pending-resend list
(FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
Cache commit happens AFTER a successful send
(FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
NAK ids[0] folds into the watermark as retail's implicit cumulative ack
(RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
with fragments), Time = current interval id, Sequence/Id/Iteration/
DataSize verbatim, checksum = fresh header hash + stored sealed checksum
(FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
original ISAAC key rides inside the sealed value - no new keystream word
is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
this slice.
- TransportStats: unconditional counters (ResendsSent,
NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.
PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.
WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.
Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.
N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.
Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.
Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1142 lines
53 KiB
C#
1142 lines
53 KiB
C#
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)
|
||
// and its NEW-buffer arm (:509-518), which never checks Complete:
|
||
// MessageBuffer.AddFragment refuses to add to the already-
|
||
// "Complete" zero-count buffer, no message ever dispatches, and the
|
||
// dead buffer stays PARKED in partialFragments forever. 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(1, 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);
|
||
}
|
||
}
|
||
}
|