test(net): N0 - ACE-behaviour double, virtual clock, lossy link
Campaign N slice N0 (docs/plans/2026-07-29-network-transport-campaign.md): the referee that slices N1-N5 are graded against, test-project only, zero production changes. - VirtualClock: Stopwatch-shaped deterministic time source (fixed 100 ns ticks) that N1 will inject behind the production TransportClock. - AceCryptoModel: verbatim port of ACE CryptoSystem Search/ConsumeKey over our IsaacRandom - 256-key window, parked-key set, Headroom/OrphanCount diagnostics (CryptoSystem.cs:8-49 cited per method). - AceSessionModel: transport-free ACE NetworkSession over raw datagrams, every rule cited to NetworkSession.cs - CRC-before-everything silent drop, cleartext-NAK early return (no timeout refresh, :283-308), 60 s timeout refresh (:329-331), exact-equality ack dedup exemption (:342-347), desired+2 NAK trigger with 1 s limit (:351-363), >window AbnormalSequenceReceived (:393-397), the :474-476 watermark hole, ack-value cache prune (:663-673), fragment gate (:532-543), seq>=2 caching (:730), Retransmission-flag resends with the ORIGINAL IssacXor (:675-686), RejectRetransmit, 2 s cleartext cumulative ack, 20 s TimeSync, EchoResponse, 120 s cache prune (:251-262). ACE's raw wrap-unsafe comparisons are modeled bug-for-bug, not fixed. - LossyLink: deterministic drop/reorder/seeded-loss fault injector, pure data structure. - FakeAceTransport: IWorldSessionTransport binding a REAL WorldSession to the model through the link, with the handshake scripted (ConnectRequest reusing the negotiation fixture layout, CharacterList, ServerReady, logoff confirmation) - genuine Connect/EnterWorld/Tick/Dispose with no sockets. - 19 new tests pin the double, including CleartextNonAckAdvancesWatermark_TheAceHole (the self-induced wedge behind scope rows TS-57/TS-58/AP-125), re-key = permanent orphan, unrequested-resend window burn, the 115-id NAK cap boundary, and a full no-socket session lifecycle with both ISAAC streams verified aligned end-to-end. Core.Net suite: 678 passed / 0 failed (659 existing + 19 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9ed43e27df
commit
7e9134b4d1
7 changed files with 2359 additions and 0 deletions
219
tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs
Normal file
219
tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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; }
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue