feat(net): N5 - loss observability, lossy decorator, the connected loss gate
Campaign N Slice N5 (docs/plans/2026-07-29-network-transport-campaign.md section 8 rung 3): the permanent removal of the loopback blindness that let #260 ship. Local ACE never drops a datagram, so every historical connected gate was structurally incapable of exercising the N1-N4 recovery machinery; from this slice on, tools/run-connected-loss-gate.ps1 runs the standard lifecycle route through deterministic seeded loss and passes only on proven non-zero recovery. Observability: - [net-tick] gains resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s reclaim/s cache= nakset= - TransportStats window deltas mirroring the acks/s cumulative-delta pattern, plus the two instantaneous depths (the unbounded-like-retail sent-packet cache watchdog and the inbound NAK set). TransportStats gains RejectsReceived (inbound RejectRetransmit packets). Counters increment unconditionally; every string is behind NetDiagnostics.ProbeNet (Code Structure Rule 5). - WorldSession.Dispose emits one cumulative [net-final] totals line so the loss gate asserts exact counters instead of reconstructing them from rounded per-second rates. - LinkStatusSnapshot.PacketLossPercentage is deliberately NOT wired: filed #261 - retail's CLinkStatusAverages formula (LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) must be located first; inventing a ratio is forbidden. N4-review F3 fold-in: - Fresh reliable sends stamp Header.Iteration = the session iteration through the same shared retail header build already cited for Time (N3) and the N4 control packets: FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84/0x00547AA8. The control-header rule now holds across all three send shapes (fresh reliable, ack, NAK). ACE reads neither Time nor Iteration inbound (campaign section 3) - wire-safe, and resends keep the stamp verbatim per the N1 rebuild rule. Loss injection (Transport/LossyTransportDecorator): - IWorldSessionTransport wrapper with deterministic seeded per-direction loss. Config via NetDiagnostics typed env properties read once: ACDREAM_NET_DROP_PCT (0 = off = default), ACDREAM_NET_DROP_SEED (default 1), ACDREAM_NET_DROP_DIR (out|in|both, default both). - Arming gate: NOTHING drops in either direction until the decorator has FORWARDED the first ENCRYPTED outbound datagram - parse-free check on length > 20 with EncryptedChecksum set in the LE flags word at bytes 4..8. The cleartext handshake always survives and the arming datagram is never a casualty; handshake-loss testing belongs to N6's ConnectResponse 0.333 s retransmit. - Structurally absent at 0%: WrapIfConfigured returns the raw transport - WorldSession's default factory is the only production seam and a normal run never constructs the decorator. Root-cause fix the gate immediately exposed: - The logoff-confirmation wait in Dispose processed inbound datagrams but never pumped the transport, so a lost S2C logoff confirmation was gap-detected but its healing NAK never went out. Retail's pump (Client::UseTime @ 0x00411C40 -> PacketController::UseTime @ 0x005410D0) runs until LogOffServer; the wait now sweeps per processed datagram, making the logoff wait the third covered blocking pump (after Tick and the handshake loops). A lost C2S logoff REQUEST remains unrecoverable by ACE design (arrival-driven NAK; a quiet client is never NAKed - campaign section 3 row 1), recorded in the gate header. Gates: - tools/run-connected-loss-gate.ps1 (-DropPct 2 -Seed 1): PASS vs local ACE - the first automated observation of packet loss in project history. Decorator ledger: dropped out=3 in=10 of forwarded out=183 in=496. [net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114 acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0 uncached-nak=0 cache=1 nakset=0. Every injected loss healed: both ACE-driven C2S resend recovery (nak-in=2 -> resends=2) and client-driven S2C NAK recovery (parked=9 -> nak-out=6) fired on a real connected route, all six checkpoints validated, graceful logout confirmed, ACE recorded the transport Disconnect. - tools/run-connected-world-lifecycle-gate.ps1 (decorator absent): PASS - zero behavior change on the no-loss baseline; the gate now defensively clears the drop env vars. - Core.Net Release: 747/747 (737 + 10 N5: decorator determinism/direction/ arming/structural-absence/env parsing, the 5% seeded WorldSession lossy lifecycle with zero message loss both ways + ACE Headroom 256, the [net-tick] field pins, the Iteration stamps). - Full solution Release: 9,763 passed / 5 skipped / 0 failed. Test-fixture note: FakeAceTransport gains AutoAdvanceOnBlockingReceive so virtual time can move during the blocking Connect()/EnterWorld() pumps - with the clock frozen there, a dropped handshake-window datagram could never be NAK-healed (a fixture artifact, not a transport property). Campaign section 9 ledger row added (SHA recorded at N6 kickoff). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
396838bb40
commit
4e290f00d8
14 changed files with 1629 additions and 27 deletions
|
|
@ -59,6 +59,21 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
|
|||
public LossyLink Link { get; }
|
||||
public AceSessionModel Model { get; }
|
||||
|
||||
/// <summary>
|
||||
/// N5: virtual-clock advance applied at the top of every BLOCKING
|
||||
/// <see cref="Receive"/> call — the session-thread Connect()/EnterWorld()
|
||||
/// pump path only; the async in-world receive owner never touches the
|
||||
/// clock. The lossy-decorator lifecycle test needs time to move during
|
||||
/// the blocking handshake pumps: with the clock frozen there, a dropped
|
||||
/// handshake-window datagram could never be NAK-healed (the 0.6 s gate
|
||||
/// never opens and the model never emits a later sequenced packet to
|
||||
/// expose the gap) — a fixture artifact, not a transport property.
|
||||
/// Zero (the default) preserves the pre-N5 fixture behavior exactly.
|
||||
/// Single-threaded by construction: blocking receives happen on the same
|
||||
/// thread that owns the clock in every test that sets this.
|
||||
/// </summary>
|
||||
public TimeSpan AutoAdvanceOnBlockingReceive { get; set; }
|
||||
|
||||
public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
|
||||
{
|
||||
Clock = clock ?? new VirtualClock();
|
||||
|
|
@ -158,6 +173,8 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
|
|||
|
||||
public int Receive(Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
|
||||
{
|
||||
if (AutoAdvanceOnBlockingReceive > TimeSpan.Zero)
|
||||
Clock.Advance(AutoAdvanceOnBlockingReceive);
|
||||
lock (_gate)
|
||||
{
|
||||
PumpServerLocked();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Net.Transport;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N5 — the deterministic loss-injection decorator that
|
||||
/// removes the loopback blindness (#260). Pins: seeded determinism, the
|
||||
/// direction mask, structural absence at 0%, the encrypted-outbound arming
|
||||
/// gate, and one full <see cref="WorldSession"/> lifecycle at 5% seeded
|
||||
/// bidirectional loss over the N0 ACE double with zero message loss and the
|
||||
/// 256-key crypto window intact.
|
||||
/// </summary>
|
||||
public sealed class LossyTransportDecoratorTests
|
||||
{
|
||||
// =====================================================================
|
||||
// Determinism
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void SameSeed_ProducesIdenticalDropPattern_DifferentSeedDiffers()
|
||||
{
|
||||
bool[] first = OutboundSurvivalPattern(seed: 42, count: 400);
|
||||
bool[] second = OutboundSurvivalPattern(seed: 42, count: 400);
|
||||
bool[] third = OutboundSurvivalPattern(seed: 43, count: 400);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.NotEqual(first, third);
|
||||
// The 25% loss was real in both directions of the comparison.
|
||||
Assert.Contains(false, first);
|
||||
Assert.Contains(true, first);
|
||||
}
|
||||
|
||||
/// <summary>Arms a 25% decorator, pushes <paramref name="count"/>
|
||||
/// encrypted datagrams through Send, and records which survived.</summary>
|
||||
private static bool[] OutboundSurvivalPattern(int seed, int count)
|
||||
{
|
||||
var inner = new RecordingTransport();
|
||||
var lossy = new LossyTransportDecorator(
|
||||
inner, dropPercent: 25, seed, NetDropDirection.Both);
|
||||
Arm(lossy, inner);
|
||||
|
||||
bool[] survived = new bool[count];
|
||||
int forwardedBefore = inner.Sent.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
lossy.Send(EncryptedDatagram(sequence: (uint)(i + 2)));
|
||||
survived[i] = inner.Sent.Count > forwardedBefore;
|
||||
forwardedBefore = inner.Sent.Count;
|
||||
}
|
||||
|
||||
return survived;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Direction mask
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DirectionOut_DropsOutboundOnly_InboundAllDelivered()
|
||||
{
|
||||
var inner = new RecordingTransport();
|
||||
var lossy = new LossyTransportDecorator(
|
||||
inner, dropPercent: 100, seed: 1, NetDropDirection.Out);
|
||||
Arm(lossy, inner);
|
||||
int armedForwardCount = inner.Sent.Count;
|
||||
|
||||
// Every post-arming outbound datagram dies at 100%.
|
||||
for (int i = 0; i < 20; i++)
|
||||
lossy.Send(EncryptedDatagram(sequence: (uint)(i + 2)));
|
||||
Assert.Equal(armedForwardCount, inner.Sent.Count);
|
||||
Assert.Equal(20, lossy.OutboundDropped);
|
||||
|
||||
// Inbound is untouched by the Out mask.
|
||||
for (int i = 0; i < 20; i++)
|
||||
inner.Inbound.Enqueue(EncryptedDatagram(sequence: (uint)(i + 2)));
|
||||
Span<byte> buffer = stackalloc byte[64];
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
Assert.True(
|
||||
lossy.Receive(buffer, TimeSpan.FromMilliseconds(50), out _) > 0);
|
||||
}
|
||||
|
||||
Assert.Equal(0, lossy.InboundDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectionIn_DropsInboundOnly_OutboundAllForwarded()
|
||||
{
|
||||
var inner = new RecordingTransport();
|
||||
var lossy = new LossyTransportDecorator(
|
||||
inner, dropPercent: 100, seed: 1, NetDropDirection.In);
|
||||
Arm(lossy, inner);
|
||||
int armedForwardCount = inner.Sent.Count;
|
||||
|
||||
// Outbound is untouched by the In mask.
|
||||
for (int i = 0; i < 20; i++)
|
||||
lossy.Send(EncryptedDatagram(sequence: (uint)(i + 2)));
|
||||
Assert.Equal(armedForwardCount + 20, inner.Sent.Count);
|
||||
Assert.Equal(0, lossy.OutboundDropped);
|
||||
|
||||
// Every queued inbound datagram is eaten; the exhausted inner then
|
||||
// reports timeout (-1) and the decorator surfaces it.
|
||||
for (int i = 0; i < 20; i++)
|
||||
inner.Inbound.Enqueue(EncryptedDatagram(sequence: (uint)(i + 2)));
|
||||
byte[] buffer = new byte[64];
|
||||
Assert.Equal(
|
||||
-1,
|
||||
lossy.Receive(buffer, TimeSpan.FromMilliseconds(50), out _));
|
||||
Assert.Equal(20, lossy.InboundDropped);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Structural absence at 0%
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void WrapIfConfigured_ZeroPercent_ReturnsTheRawTransport()
|
||||
{
|
||||
int savedPercent = NetDiagnostics.NetDropPercent;
|
||||
int savedSeed = NetDiagnostics.NetDropSeed;
|
||||
NetDropDirection savedDir = NetDiagnostics.NetDropDir;
|
||||
try
|
||||
{
|
||||
var inner = new RecordingTransport();
|
||||
|
||||
NetDiagnostics.NetDropPercent = 0;
|
||||
Assert.Same(inner, LossyTransportDecorator.WrapIfConfigured(inner));
|
||||
|
||||
NetDiagnostics.NetDropPercent = 2;
|
||||
NetDiagnostics.NetDropSeed = 7;
|
||||
NetDiagnostics.NetDropDir = NetDropDirection.Both;
|
||||
IWorldSessionTransport wrapped =
|
||||
LossyTransportDecorator.WrapIfConfigured(inner);
|
||||
Assert.IsType<LossyTransportDecorator>(wrapped);
|
||||
Assert.NotSame(inner, wrapped);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NetDiagnostics.NetDropPercent = savedPercent;
|
||||
NetDiagnostics.NetDropSeed = savedSeed;
|
||||
NetDiagnostics.NetDropDir = savedDir;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvParsing_RejectsOutOfRangeAndGarbage()
|
||||
{
|
||||
Assert.Equal(0, NetDiagnostics.ParseDropPercent(null));
|
||||
Assert.Equal(0, NetDiagnostics.ParseDropPercent(""));
|
||||
Assert.Equal(0, NetDiagnostics.ParseDropPercent("banana"));
|
||||
Assert.Equal(0, NetDiagnostics.ParseDropPercent("-1"));
|
||||
Assert.Equal(0, NetDiagnostics.ParseDropPercent("101"));
|
||||
Assert.Equal(2, NetDiagnostics.ParseDropPercent("2"));
|
||||
Assert.Equal(100, NetDiagnostics.ParseDropPercent("100"));
|
||||
|
||||
Assert.Equal(
|
||||
NetDropDirection.Both, NetDiagnostics.ParseDropDirection(null));
|
||||
Assert.Equal(
|
||||
NetDropDirection.Out, NetDiagnostics.ParseDropDirection("out"));
|
||||
Assert.Equal(
|
||||
NetDropDirection.In, NetDiagnostics.ParseDropDirection("In"));
|
||||
Assert.Equal(
|
||||
NetDropDirection.Both, NetDiagnostics.ParseDropDirection("both"));
|
||||
Assert.Equal(
|
||||
NetDropDirection.Both, NetDiagnostics.ParseDropDirection("weird"));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// The arming gate
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void NothingDrops_UntilTheFirstEncryptedOutboundHasBeenForwarded()
|
||||
{
|
||||
var inner = new RecordingTransport();
|
||||
var lossy = new LossyTransportDecorator(
|
||||
inner, dropPercent: 100, seed: 1, NetDropDirection.Both);
|
||||
|
||||
// Pre-arming: cleartext outbound (the handshake shape) always
|
||||
// forwards, even at 100%.
|
||||
for (int i = 0; i < 5; i++)
|
||||
lossy.Send(CleartextDatagram());
|
||||
Assert.Equal(5, inner.Sent.Count);
|
||||
Assert.False(lossy.IsArmed);
|
||||
Assert.Equal(0, lossy.OutboundDropped);
|
||||
|
||||
// Pre-arming: inbound always delivers.
|
||||
inner.Inbound.Enqueue(EncryptedDatagram(sequence: 2));
|
||||
byte[] buffer = new byte[64];
|
||||
Assert.True(
|
||||
lossy.Receive(buffer, TimeSpan.FromMilliseconds(50), out _) > 0);
|
||||
Assert.Equal(0, lossy.InboundDropped);
|
||||
|
||||
// The FIRST encrypted outbound datagram both forwards (it is the
|
||||
// arming witness, never a casualty) and arms the decorator.
|
||||
lossy.Send(EncryptedDatagram(sequence: 2));
|
||||
Assert.Equal(6, inner.Sent.Count);
|
||||
Assert.True(lossy.IsArmed);
|
||||
Assert.Equal(0, lossy.OutboundDropped);
|
||||
|
||||
// From the next datagram on, 100% eats everything in both
|
||||
// directions.
|
||||
lossy.Send(EncryptedDatagram(sequence: 3));
|
||||
lossy.Send(CleartextDatagram());
|
||||
Assert.Equal(6, inner.Sent.Count);
|
||||
Assert.Equal(2, lossy.OutboundDropped);
|
||||
inner.Inbound.Enqueue(EncryptedDatagram(sequence: 3));
|
||||
Assert.Equal(
|
||||
-1,
|
||||
lossy.Receive(buffer, TimeSpan.FromMilliseconds(50), out _));
|
||||
Assert.Equal(1, lossy.InboundDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortOrCleartextDatagrams_NeverArm()
|
||||
{
|
||||
var inner = new RecordingTransport();
|
||||
var lossy = new LossyTransportDecorator(
|
||||
inner, dropPercent: 100, seed: 1, NetDropDirection.Both);
|
||||
|
||||
// A datagram at exactly the header size cannot be an encrypted
|
||||
// reliable packet (length must EXCEED 20), and cleartext flags
|
||||
// never arm regardless of length.
|
||||
lossy.Send(new byte[PacketHeader.Size]);
|
||||
lossy.Send(CleartextDatagram());
|
||||
Assert.False(lossy.IsArmed);
|
||||
Assert.Equal(2, inner.Sent.Count);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// The WorldSession-level lossy run (deliverable #5's unit shape):
|
||||
// 5% seeded bidirectional loss around the N0 ACE double — the scripted
|
||||
// session completes, every message dispatches both ways, and ACE's
|
||||
// 256-key crypto window is intact at convergence.
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256()
|
||||
{
|
||||
var fake = new FakeAceTransport
|
||||
{
|
||||
// Time must move during the blocking Connect()/EnterWorld()
|
||||
// pumps: the decorator arms at the first encrypted outbound
|
||||
// (the enter-world request), so the ServerReady response is
|
||||
// already droppable — and healing it needs the NAK gate to open
|
||||
// and the model's 20 s TimeSync cadence to expose the gap.
|
||||
AutoAdvanceOnBlockingReceive = TimeSpan.FromSeconds(3),
|
||||
};
|
||||
var lossy = new LossyTransportDecorator(
|
||||
fake, dropPercent: 5, seed: 424242, NetDropDirection.Both);
|
||||
var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
lossy);
|
||||
session.TransportClockSource =
|
||||
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
|
||||
try
|
||||
{
|
||||
session.Connect(
|
||||
"testaccount", "testpassword", TimeSpan.FromSeconds(10));
|
||||
session.EnterWorld(0, TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
|
||||
|
||||
int s2cReceived = 0;
|
||||
session.ServerMessageReceived += m =>
|
||||
{
|
||||
if (m.Message.StartsWith("s2c ", StringComparison.Ordinal))
|
||||
s2cReceived++;
|
||||
};
|
||||
int c2sDispatched = 0;
|
||||
fake.Model.MessageDispatched += body =>
|
||||
{
|
||||
if (body.AsSpan().IndexOf("c2s "u8) >= 0)
|
||||
c2sDispatched++;
|
||||
};
|
||||
|
||||
const int MessagesEachWay = 1_000;
|
||||
for (int i = 0; i < MessagesEachWay; i++)
|
||||
{
|
||||
fake.Clock.Advance(TimeSpan.FromMilliseconds(25));
|
||||
session.SendTalk($"c2s {i}");
|
||||
fake.Model.EnqueueGameMessage(
|
||||
BuildServerMessage($"s2c {i}"),
|
||||
GameMessageGroup.UIQueue);
|
||||
fake.PumpServer();
|
||||
session.Tick();
|
||||
if ((i & 15) == 0)
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
// Convergence: keep a C2S trickle flowing (ACE's NAK is
|
||||
// arrival-driven — a quiet client is never NAKed, campaign §3
|
||||
// row 1) until every message has landed on both sides.
|
||||
int trickle = 0;
|
||||
DateTime deadline = DateTime.UtcNow.AddSeconds(60);
|
||||
while (DateTime.UtcNow < deadline
|
||||
&& (s2cReceived != MessagesEachWay
|
||||
|| c2sDispatched != MessagesEachWay))
|
||||
{
|
||||
fake.Clock.Advance(TimeSpan.FromMilliseconds(500));
|
||||
session.SendTalk($"trickle {trickle++}");
|
||||
fake.PumpServer();
|
||||
session.Tick();
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
int quietIterations = 0;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
fake.Clock.Advance(TimeSpan.FromMilliseconds(500));
|
||||
if (session.Transport!.Outbound.CacheDepth > 1
|
||||
&& ++quietIterations % 8 == 0)
|
||||
{
|
||||
session.SendTalk($"trickle {trickle++}");
|
||||
}
|
||||
|
||||
fake.PumpServer();
|
||||
session.Tick();
|
||||
Thread.Sleep(1);
|
||||
|
||||
if (s2cReceived == MessagesEachWay
|
||||
&& c2sDispatched == MessagesEachWay
|
||||
&& session.Transport.Inbound.NakCount == 0
|
||||
&& session.Transport.Outbound.PendingResendCount == 0
|
||||
&& session.Transport.Outbound.CacheDepth <= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string ledger =
|
||||
$"s2c={s2cReceived} c2s={c2sDispatched} "
|
||||
+ $"dropped-out={lossy.OutboundDropped} "
|
||||
+ $"dropped-in={lossy.InboundDropped} "
|
||||
+ $"resends={session.Transport!.Stats.ResendsSent} "
|
||||
+ $"naks-sent={session.Transport.Stats.NaksSent} "
|
||||
+ $"headroom={fake.Model.Crypto.Headroom}";
|
||||
|
||||
// Zero message loss, both directions.
|
||||
Assert.True(s2cReceived == MessagesEachWay, $"S2C loss: {ledger}");
|
||||
Assert.True(
|
||||
c2sDispatched == MessagesEachWay, $"C2S loss: {ledger}");
|
||||
|
||||
// The decorator injected real loss in both directions, and the
|
||||
// N1–N4 machinery healed it.
|
||||
Assert.True(lossy.OutboundDropped > 0, ledger);
|
||||
Assert.True(lossy.InboundDropped > 0, ledger);
|
||||
Assert.True(session.Transport.Stats.ResendsSent > 0, ledger);
|
||||
Assert.True(session.Transport.Stats.NaksSent > 0, ledger);
|
||||
|
||||
// ACE's crypto search window never eroded (no re-key, no
|
||||
// unrequested resend) — Headroom back to the full 256.
|
||||
Assert.Equal(256, fake.Model.Crypto.Headroom);
|
||||
Assert.Equal(0, fake.Model.Crypto.OrphanCount);
|
||||
Assert.False(fake.Model.IsTerminated);
|
||||
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Fixture helpers
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Arms the decorator by forwarding one encrypted datagram
|
||||
/// (the arming witness is never dropped).</summary>
|
||||
private static void Arm(
|
||||
LossyTransportDecorator lossy,
|
||||
RecordingTransport inner)
|
||||
{
|
||||
int before = inner.Sent.Count;
|
||||
lossy.Send(EncryptedDatagram(sequence: 2));
|
||||
Assert.Equal(before + 1, inner.Sent.Count);
|
||||
Assert.True(lossy.IsArmed);
|
||||
}
|
||||
|
||||
private static byte[] EncryptedDatagram(uint sequence)
|
||||
{
|
||||
byte[] buffer = new byte[PacketHeader.Size + 8];
|
||||
new PacketHeader
|
||||
{
|
||||
Sequence = sequence,
|
||||
Flags = PacketHeaderFlags.BlobFragments
|
||||
| PacketHeaderFlags.EncryptedChecksum,
|
||||
DataSize = 8,
|
||||
}.Pack(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] CleartextDatagram()
|
||||
{
|
||||
byte[] buffer = new byte[PacketHeader.Size + 4];
|
||||
new PacketHeader
|
||||
{
|
||||
Sequence = 2,
|
||||
Flags = PacketHeaderFlags.AckSequence,
|
||||
DataSize = 4,
|
||||
}.Pack(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] BuildServerMessage(string text)
|
||||
{
|
||||
var writer = new PacketWriter(64 + text.Length);
|
||||
writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0
|
||||
writer.WriteString16L(text);
|
||||
writer.WriteUInt32(1); // ChatMessageType
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>In-memory transport double: records outbound datagrams and
|
||||
/// serves a caller-stocked inbound queue.</summary>
|
||||
private sealed class RecordingTransport : IWorldSessionTransport
|
||||
{
|
||||
public List<byte[]> Sent { get; } = new();
|
||||
public Queue<byte[]> Inbound { get; } = new();
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Send(ReadOnlySpan<byte> datagram) =>
|
||||
Sent.Add(datagram.ToArray());
|
||||
|
||||
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) =>
|
||||
Sent.Add(datagram.ToArray());
|
||||
|
||||
public int Receive(
|
||||
Span<byte> destination,
|
||||
TimeSpan timeout,
|
||||
out IPEndPoint? from)
|
||||
{
|
||||
if (Inbound.Count == 0)
|
||||
{
|
||||
from = null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
byte[] datagram = Inbound.Dequeue();
|
||||
datagram.CopyTo(destination);
|
||||
from = new IPEndPoint(IPAddress.Loopback, 9000);
|
||||
return datagram.Length;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Inbound.Count == 0)
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
byte[] datagram = Inbound.Dequeue();
|
||||
datagram.CopyTo(destination);
|
||||
return ValueTask.FromResult(new NetReceiveResult(
|
||||
datagram.Length,
|
||||
new IPEndPoint(IPAddress.Loopback, 9000)));
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ public sealed class OutboundReliableTransportTests
|
|||
private const uint ClientSeed = 0x11AA22BBu;
|
||||
private const uint ServerSeed = 0x33CC44DDu;
|
||||
private const uint ClientId = 0x1234u;
|
||||
private const ushort SessionIteration = 0x0007;
|
||||
private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
|
||||
|
||||
// =====================================================================
|
||||
|
|
@ -183,6 +184,10 @@ public sealed class OutboundReliableTransportTests
|
|||
// N3 fold-in: fresh sends stamp the current interval id (the clock
|
||||
// starts at 1) — FlowQueue::TransmitNewPackets @ 0x00547A60.
|
||||
Assert.Equal((ushort)1, originalHeader.Time);
|
||||
// N5 fold-in (N4 review F3): fresh sends stamp the session iteration
|
||||
// through the same shared header build (0x00547A84/0x00547AA8),
|
||||
// completing the control-header rule across all three send shapes.
|
||||
Assert.Equal(SessionIteration, originalHeader.Iteration);
|
||||
|
||||
// 1.2 s later (interval id 1 → 3) the server NAKs sequence 2.
|
||||
virtualClock.Advance(TimeSpan.FromSeconds(1.2));
|
||||
|
|
@ -345,7 +350,11 @@ public sealed class OutboundReliableTransportTests
|
|||
clock.Update();
|
||||
Assert.Equal((ushort)6, clock.IntervalId);
|
||||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
|
||||
Assert.Equal((ushort)6, PacketHeader.Unpack(Assert.Single(sent)).Time);
|
||||
PacketHeader freshHeader = PacketHeader.Unpack(Assert.Single(sent));
|
||||
Assert.Equal((ushort)6, freshHeader.Time);
|
||||
// N5 fold-in (N4 review F3): the fresh send carries the session
|
||||
// iteration, and the resend below keeps it verbatim.
|
||||
Assert.Equal(SessionIteration, freshHeader.Iteration);
|
||||
|
||||
// The interval advances again; the resend carries the CURRENT id,
|
||||
// newer than the fresh-send stamp.
|
||||
|
|
@ -355,7 +364,9 @@ public sealed class OutboundReliableTransportTests
|
|||
Nak(queue, 2u);
|
||||
sent.Clear();
|
||||
queue.TransmitPendingResends();
|
||||
Assert.Equal((ushort)8, PacketHeader.Unpack(Assert.Single(sent)).Time);
|
||||
PacketHeader resentHeader = PacketHeader.Unpack(Assert.Single(sent));
|
||||
Assert.Equal((ushort)8, resentHeader.Time);
|
||||
Assert.Equal(SessionIteration, resentHeader.Iteration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -528,6 +539,7 @@ public sealed class OutboundReliableTransportTests
|
|||
var queue = new OutboundFlowQueue(
|
||||
MakeIsaac(ClientSeed),
|
||||
(ushort)ClientId,
|
||||
SessionIteration,
|
||||
clock,
|
||||
stats,
|
||||
static _ => { });
|
||||
|
|
@ -572,6 +584,7 @@ public sealed class OutboundReliableTransportTests
|
|||
var queue = new OutboundFlowQueue(
|
||||
MakeIsaac(ClientSeed),
|
||||
(ushort)ClientId,
|
||||
SessionIteration,
|
||||
clock,
|
||||
stats,
|
||||
datagram => sent.Add(datagram.ToArray()));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue