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>
This commit is contained in:
parent
7e9134b4d1
commit
e395861053
3 changed files with 1153 additions and 151 deletions
|
|
@ -251,7 +251,7 @@ verbatim in each implementer prompt.
|
|||
|
||||
| Slice | Status | Commit | Notes |
|
||||
|---|---|---|---|
|
||||
| N0 | pending | — | |
|
||||
| N0 | complete | `7e9134b4` + the `test(net): N0 fix-up` commit | ACE-behaviour double + virtual clock + lossy link; the review fix-up added the `Session.CheckState` inbound gate, faithful `SendBundle` coalescing/splitting, two-phase termination, an ACE-loose C2S fragment parse, and ACE's MessageBuffer edge cases. 687 Core.Net tests green. |
|
||||
| N1 | pending | — | |
|
||||
| N2 | pending | — | |
|
||||
| N3 | pending | — | |
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -17,11 +17,110 @@ public sealed class AceSessionModelTests
|
|||
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: 5); // seq 2..6
|
||||
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).
|
||||
|
|
@ -44,10 +143,23 @@ public sealed class AceSessionModelTests
|
|||
model.Update();
|
||||
Assert.Empty(OfExactFlags(model.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
|
||||
|
||||
// Limiter reopens strictly after 1 s.
|
||||
clock.Advance(TimeSpan.FromSeconds(1.1));
|
||||
// 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));
|
||||
|
|
@ -234,11 +346,181 @@ public sealed class AceSessionModelTests
|
|||
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
|
||||
|
|
@ -246,10 +528,14 @@ public sealed class AceSessionModelTests
|
|||
// 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);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(2)); // 61 s since the last real packet
|
||||
// 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);
|
||||
|
|
@ -285,6 +571,10 @@ public sealed class AceSessionModelTests
|
|||
Assert.Empty(OfExactFlags(model2.TakePendingDatagrams(), PacketHeaderFlags.RequestRetransmit));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Send side — retransmit, ack, echo, cache prune, bundling
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Retransmit_ServesCachedBytes_WithRetransmissionFlag_AndNoNewIsaacWord()
|
||||
{
|
||||
|
|
@ -396,27 +686,119 @@ public sealed class AceSessionModelTests
|
|||
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 across 121 s with periodic client packets
|
||||
// (each refreshes the 60 s deadline) but no server pumps.
|
||||
// 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)));
|
||||
clock.Advance(TimeSpan.FromSeconds(21));
|
||||
model.Receive(client.BuildGameMessagePacket(MakeMessage(4)));
|
||||
model.Update(); // t = 100 s: prune runs, the seq-2 entry is well inside
|
||||
Assert.Contains(2u, model.CachedPacketSequences);
|
||||
|
||||
model.Update(); // prune (:251-262): the seq-2 packet is 121 s old (> 120)
|
||||
// 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(4, 2u));
|
||||
model.Receive(client.BuildCleartextNak(6, 2u));
|
||||
model.Update();
|
||||
byte[] reject = Assert.Single(
|
||||
model.TakePendingDatagrams(),
|
||||
|
|
@ -459,8 +841,9 @@ public sealed class AceSessionModelTests
|
|||
/// <summary>
|
||||
/// A model with the handshake completed the way a real session does it:
|
||||
/// LoginRequest → ConnectRequest (flushed + discarded; primes the S2C
|
||||
/// sequence to 0) → ConnectResponse → the immediate first TimeSync
|
||||
/// (flushed + discarded; S2C sequence 2, S2C keystream word 1, cached).
|
||||
/// 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()
|
||||
|
|
@ -510,6 +893,15 @@ public sealed class AceSessionModelTests
|
|||
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) =>
|
||||
|
|
@ -522,6 +914,28 @@ public sealed class AceSessionModelTests
|
|||
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 =
|
||||
|
|
@ -618,7 +1032,63 @@ public sealed class AceSessionModelTests
|
|||
return PacketCodec.Encode(header, fragment, _outboundIsaac);
|
||||
}
|
||||
|
||||
public byte[] BuildCleartextAck(uint headerSequence, uint ackValue)
|
||||
/// <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);
|
||||
|
|
@ -626,11 +1096,13 @@ public sealed class AceSessionModelTests
|
|||
new PacketHeader
|
||||
{
|
||||
Sequence = headerSequence,
|
||||
Flags = PacketHeaderFlags.AckSequence,
|
||||
Flags = encrypted
|
||||
? PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum
|
||||
: PacketHeaderFlags.AckSequence,
|
||||
Id = (ushort)ClientId,
|
||||
},
|
||||
body,
|
||||
outboundIsaac: null);
|
||||
encrypted ? _outboundIsaac : null);
|
||||
}
|
||||
|
||||
public byte[] BuildCleartextNak(uint headerSequence, params uint[] ids)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue