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:
Erik 2026-07-29 16:26:06 +02:00
parent 396838bb40
commit 4e290f00d8
14 changed files with 1629 additions and 27 deletions

View file

@ -0,0 +1,143 @@
using System.Net;
using AcDream.Core.Net.Tests.Transport;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Campaign N Slice N5 — the <c>[net-tick]</c> loss-observability extension.
/// The line shape is pinned through the extracted formatter (no wall-clock
/// window needed), and one real probe-on session proves the once-per-second
/// emission path carries the new fields end-to-end. The probe-off
/// steady-state cost is covered by the existing zero-alloc send-path test
/// (<c>OutboundReliableTransportTests.SendGameMessage_SteadyState_
/// AllocatesNothingOnceThePoolWarms</c>) — counters increment
/// unconditionally; string work is probe-gated.
/// </summary>
public sealed class NetProbeTests
{
[Fact]
public void FormatNetTickLine_CarriesTheN5TransportFields()
{
string line = WorldSession.FormatNetTickLine(
windowSeconds: 2.0,
processed: 10,
queueDepth: 3,
budgetBreaks: 1,
maxGapMs: 17.4,
sends: 8,
acks: 2,
resends: 4,
naksOut: 6,
naksIn: 8,
rejsIn: 2,
dupDrops: 10,
parked: 12,
reclaimed: 2,
cacheDepth: 5,
nakSetDepth: 7,
WorldSession.State.InWorld);
Assert.Equal(
"[net-tick] in/s=5 q=3 budget-breaks=1 maxgap=17ms out/s=4"
+ " acks/s=1 resend/s=2 nak-out/s=3 nak-in/s=4 rej-in/s=1"
+ " dup-drop/s=5 parked/s=6 reclaim/s=1 cache=5 nakset=7"
+ " st=InWorld",
line);
}
[Fact]
public void ProbeOn_EmitsTheExtendedNetTickLine_OncePerSecond()
{
bool savedProbe = NetDiagnostics.ProbeNet;
TextWriter savedOut = Console.Out;
var captured = new LockedStringWriter();
var fake = new FakeAceTransport();
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
fake);
try
{
NetDiagnostics.ProbeNet = true;
Console.SetOut(captured);
session.Connect(
"testaccount", "testpassword", TimeSpan.FromSeconds(10));
session.EnterWorld(0, TimeSpan.FromSeconds(10));
// The probe window is one REAL second of Tick cadence.
DateTime deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline
&& !captured.Snapshot().Contains(
"[net-tick]", StringComparison.Ordinal))
{
session.Tick();
Thread.Sleep(25);
}
}
finally
{
session.Dispose();
Console.SetOut(savedOut);
NetDiagnostics.ProbeNet = savedProbe;
}
string output = captured.Snapshot();
string tickLine = output
.Split('\n')
.First(l => l.Contains("[net-tick]", StringComparison.Ordinal));
foreach (string field in new[]
{
"resend/s=", "nak-out/s=", "nak-in/s=", "rej-in/s=",
"dup-drop/s=", "parked/s=", "reclaim/s=", "cache=", "nakset=",
})
{
Assert.Contains(field, tickLine, StringComparison.Ordinal);
}
// Dispose also emitted the cumulative [net-final] totals the
// connected loss gate parses.
Assert.Contains("[net-final] resends=", output, StringComparison.Ordinal);
Assert.Contains(" nak-out=", output, StringComparison.Ordinal);
Assert.Contains(" nak-in=", output, StringComparison.Ordinal);
}
/// <summary>
/// Console capture that is safe to snapshot while other threads write:
/// xunit runs test classes in parallel and any of them may hit
/// <c>Console.WriteLine</c> while this test holds the console. A plain
/// <see cref="StringWriter"/> snapshot races its own writers
/// (<c>StringBuilder.ToString</c> mid-append throws).
/// </summary>
private sealed class LockedStringWriter : TextWriter
{
private readonly System.Text.StringBuilder _buffer = new();
private readonly object _gate = new();
public override System.Text.Encoding Encoding =>
System.Text.Encoding.Unicode;
public override void Write(char value)
{
lock (_gate)
{
_buffer.Append(value);
}
}
public override void Write(string? value)
{
lock (_gate)
{
_buffer.Append(value);
}
}
public string Snapshot()
{
lock (_gate)
{
return _buffer.ToString();
}
}
}
}

View file

@ -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();

View file

@ -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
// N1N4 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;
}
}

View file

@ -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()));