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>
252 lines
9.8 KiB
C#
252 lines
9.8 KiB
C#
using System.Buffers.Binary;
|
|
using System.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Net.Packets;
|
|
|
|
namespace AcDream.Core.Net.Tests.Transport;
|
|
|
|
/// <summary>
|
|
/// An <see cref="IWorldSessionTransport"/> that binds a REAL
|
|
/// <see cref="WorldSession"/> to an <see cref="AceSessionModel"/> through a
|
|
/// <see cref="LossyLink"/> — no sockets anywhere. Outbound sends run through
|
|
/// the link into the model; the model's emitted datagrams run through the
|
|
/// link into the queue that <c>Receive</c>/<c>ReceiveAsync</c> serve.
|
|
///
|
|
/// <para>
|
|
/// The handshake is scripted against the model's events so a test can run a
|
|
/// genuine <c>Connect()</c> / <c>EnterWorld()</c> / <c>Tick()</c> /
|
|
/// <c>Dispose()</c> lifecycle:
|
|
/// <list type="number">
|
|
/// <item>LoginRequest → the model answers with a ConnectRequest carrying
|
|
/// the 32-byte optional (server time, cookie, client id, both ISAAC
|
|
/// seeds) — the same layout the existing negotiation fixture
|
|
/// (<c>WorldSessionNegotiationShutdownTests.BuildConnectRequest</c>)
|
|
/// pins.</item>
|
|
/// <item>ConnectResponse (cookie match) → the model enqueues a
|
|
/// CharacterList (0xF658) with one selectable character.</item>
|
|
/// <item>CharacterEnterWorldRequest (0xF7C8) → ServerReady (0xF7DF).</item>
|
|
/// <item>CharacterLogOff (0xF653 request) → the opcode-only 0xF653
|
|
/// confirmation, so <c>Dispose()</c> completes its retail graceful
|
|
/// logout instead of burning the 35 s confirmation timeout.</item>
|
|
/// </list>
|
|
/// Because the model seeds its C2S verifier and S2C keystream from the same
|
|
/// seeds it hands out in the ConnectRequest, post-handshake encrypted
|
|
/// traffic verifies in both directions.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Thread-safety: the model is single-threaded, so every model interaction
|
|
/// happens under one lock. <c>ReceiveAsync</c> (the session's background
|
|
/// receive loop) waits on a semaphore counting queued deliverables.
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class FakeAceTransport : IWorldSessionTransport
|
|
{
|
|
public const uint DefaultClientSeed = 0x2B6D6F87u;
|
|
public const uint DefaultServerSeed = 0x9A3C51E4u;
|
|
public const uint DefaultClientId = 0x1234u;
|
|
public const ulong DefaultCookie = 0xFEEDFACECAFEBABEUL;
|
|
public const uint DefaultCharacterId = 0x50000001u;
|
|
public const string DefaultCharacterName = "+Acdream";
|
|
public const string DefaultAccountName = "testaccount";
|
|
|
|
private readonly object _gate = new();
|
|
private readonly SemaphoreSlim _deliverable = new(0);
|
|
private readonly Queue<byte[]> _toClient = new();
|
|
private readonly IPEndPoint _serverEndpoint = new(IPAddress.Loopback, 9000);
|
|
|
|
public VirtualClock Clock { get; }
|
|
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();
|
|
Link = link ?? new LossyLink();
|
|
Model = new AceSessionModel(
|
|
Clock,
|
|
DefaultClientSeed,
|
|
DefaultServerSeed,
|
|
DefaultClientId,
|
|
DefaultCookie);
|
|
|
|
Model.LoginRequestReceived += () => Model.SendConnectRequest();
|
|
Model.ConnectResponseAccepted += () =>
|
|
Model.EnqueueGameMessage(BuildCharacterListBody(), GameMessageGroup.UIQueue);
|
|
Model.MessageDispatched += OnClientMessage;
|
|
}
|
|
|
|
private void OnClientMessage(byte[] body)
|
|
{
|
|
if (body.Length < 4)
|
|
return;
|
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
|
switch (opcode)
|
|
{
|
|
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
|
|
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
|
|
// WorldSession.EnterWorld blocks on this opcode.
|
|
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
|
|
break;
|
|
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
|
|
// ACE echoes the opcode-only confirmation; WorldSession.Dispose
|
|
// waits for it before sending the transport Disconnect.
|
|
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(CharacterLogOff.Opcode), GameMessageGroup.UIQueue);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- IWorldSessionTransport ----
|
|
|
|
public void Send(ReadOnlySpan<byte> datagram) => SendCore(datagram);
|
|
|
|
// WorldSession sends the ConnectResponse to port+1; the double serves
|
|
// both listeners from one model, like ACE's single-process server.
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) => SendCore(datagram);
|
|
|
|
private void SendCore(ReadOnlySpan<byte> datagram)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
foreach (byte[] delivered in Link.Transmit(LinkDirection.ClientToServer, datagram))
|
|
Model.Receive(delivered);
|
|
PumpServerLocked();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run one server frame (model Update + S2C link delivery) without any
|
|
/// client traffic — the hook tests use after advancing the clock or
|
|
/// enqueuing server-side messages.
|
|
/// </summary>
|
|
public void PumpServer()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
PumpServerLocked();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// N2 test hook: deliver raw bytes straight into the client's receive
|
|
/// queue, bypassing both the model and the link. Used for late
|
|
/// byte-identical redelivery of a dropped S2C datagram (ACE's cached
|
|
/// packet shape), duplicate injections, and crafted sequence-0 control
|
|
/// packets.
|
|
/// </summary>
|
|
public void InjectServerDatagram(byte[] datagram)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_toClient.Enqueue((byte[])datagram.Clone());
|
|
_deliverable.Release();
|
|
}
|
|
}
|
|
|
|
private void PumpServerLocked()
|
|
{
|
|
Model.Update();
|
|
foreach (byte[] outbound in Model.TakePendingDatagrams())
|
|
{
|
|
foreach (byte[] delivered in Link.Transmit(LinkDirection.ServerToClient, outbound))
|
|
{
|
|
_toClient.Enqueue(delivered);
|
|
_deliverable.Release();
|
|
}
|
|
}
|
|
}
|
|
|
|
public int Receive(Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
|
|
{
|
|
if (AutoAdvanceOnBlockingReceive > TimeSpan.Zero)
|
|
Clock.Advance(AutoAdvanceOnBlockingReceive);
|
|
lock (_gate)
|
|
{
|
|
PumpServerLocked();
|
|
}
|
|
|
|
if (timeout < TimeSpan.Zero)
|
|
timeout = TimeSpan.Zero;
|
|
if (!_deliverable.Wait(timeout))
|
|
{
|
|
from = null;
|
|
return -1; // NetClient.Receive's timeout contract
|
|
}
|
|
|
|
from = _serverEndpoint;
|
|
lock (_gate)
|
|
{
|
|
byte[] datagram = _toClient.Dequeue();
|
|
datagram.CopyTo(destination);
|
|
return datagram.Length;
|
|
}
|
|
}
|
|
|
|
public async ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await _deliverable.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
lock (_gate)
|
|
{
|
|
byte[] datagram = _toClient.Dequeue();
|
|
datagram.CopyTo(destination);
|
|
return new NetReceiveResult(datagram.Length, _serverEndpoint);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// WorldSession disposes the transport only after cancelling and
|
|
// joining its receive task, so no waiter can be parked on the
|
|
// semaphore here. SemaphoreSlim without AvailableWaitHandle holds no
|
|
// unmanaged state — deliberately left to the GC to keep a hypothetical
|
|
// late waiter from hitting ObjectDisposedException.
|
|
}
|
|
|
|
// ---- scripted server content ----
|
|
|
|
/// <summary>
|
|
/// Minimal CharacterList (0xF658) matching <c>CharacterList.Parse</c>:
|
|
/// status, active characters, deleted characters, slot count, account,
|
|
/// turbine chat, ToD flag.
|
|
/// </summary>
|
|
private static byte[] BuildCharacterListBody()
|
|
{
|
|
var writer = new PacketWriter(96);
|
|
writer.WriteUInt32(CharacterList.Opcode);
|
|
writer.WriteUInt32(0); // status
|
|
writer.WriteUInt32(1); // active count
|
|
writer.WriteUInt32(DefaultCharacterId);
|
|
writer.WriteString16L(DefaultCharacterName);
|
|
writer.WriteUInt32(0); // secondsGreyedOut
|
|
writer.WriteUInt32(0); // deleted count
|
|
writer.WriteUInt32(11); // slot count
|
|
writer.WriteString16L(DefaultAccountName);
|
|
writer.WriteUInt32(1); // useTurbineChat
|
|
writer.WriteUInt32(1); // hasThroneOfDestiny
|
|
return writer.ToArray();
|
|
}
|
|
|
|
private static byte[] BuildOpcodeOnlyBody(uint opcode)
|
|
{
|
|
byte[] body = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, opcode);
|
|
return body;
|
|
}
|
|
}
|