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:
Erik 2026-07-29 11:07:13 +02:00
parent 9ed43e27df
commit 7e9134b4d1
7 changed files with 2359 additions and 0 deletions

View file

@ -0,0 +1,112 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Cryptography;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// Faithful port of ACE's server-side C2S checksum-key discipline
/// (<c>references/ACE/Source/ACE.Common/Cryptography/CryptoSystem.cs</c>)
/// over acdream's <see cref="IsaacRandom"/>. This is the exact machinery
/// Coldeve uses to verify every encrypted client packet, so the double must
/// reproduce its behavior word-for-word — in particular the 256-key search
/// window, the parked-key set ("xors"), and the way an unexpected key
/// permanently orphans window capacity.
///
/// <para>
/// Behavior summary (all ACE, none invented):
/// <list type="bullet">
/// <item>The keystream is one ISAAC word per ENCRYPTED packet, in the
/// order the client SENT them (drew them), not arrival order.</item>
/// <item><see cref="Search"/> walks forward at most
/// <see cref="MaximumEffortLevel"/> |xors| words hunting for the
/// presented key, parking every skipped word in the xors set.</item>
/// <item><see cref="ConsumeKey"/> advances the wheel when the presented
/// key is the current one, otherwise un-parks it from xors.</item>
/// <item>A key that is BEHIND the wheel (already consumed) can never be
/// found again — searching for it burns the remaining window.</item>
/// </list>
/// </para>
/// </summary>
internal sealed class AceCryptoModel
{
/// <summary>CryptoSystem.cs:8 — the 256-key search window.</summary>
public const int MaximumEffortLevel = 256;
private readonly IsaacRandom _keystream;
/// <summary>
/// CryptoSystem.cs:9 — keys the search walked past while hunting for an
/// out-of-order arrival, parked so the retransmission (carrying the
/// ORIGINAL key) can still verify.
/// </summary>
private readonly HashSet<uint> _xors = new();
/// <summary>CryptoSystem.cs:10 — the next expected keystream word.</summary>
public uint CurrentKey { get; private set; }
/// <summary>
/// CryptoSystem.cs:11-14 — seed the ISAAC wheel and pre-draw the first
/// key. ACE's <c>CryptoSystem(uint seed)</c> passes
/// <c>BitConverter.GetBytes(seed)</c> (little-endian), which we mirror.
/// </summary>
public AceCryptoModel(uint seed)
{
Span<byte> seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
_keystream = new IsaacRandom(seedBytes);
CurrentKey = _keystream.Next();
}
/// <summary>
/// Remaining search capacity: 256 |xors|. Every parked key that is
/// never consumed (an orphan — e.g. from a re-keyed resend) shrinks this
/// permanently. When it reaches zero, the next packet loss is
/// unrecoverable.
/// </summary>
public int Headroom => MaximumEffortLevel - _xors.Count;
/// <summary>
/// Number of currently parked keys. A parked key is either a pending
/// retransmission's original key (healthy, recovered on arrival) or a
/// permanent orphan (the client re-keyed the resend and the original
/// word will never be presented).
/// </summary>
public int OrphanCount => _xors.Count;
/// <summary>
/// CryptoSystem.cs:19-29 — advance the wheel if <paramref name="x"/> is
/// the current key; otherwise remove it from the parked set.
/// </summary>
public void ConsumeKey(uint x)
{
if (CurrentKey == x)
CurrentKey = _keystream.Next();
else
_xors.Remove(x);
}
/// <summary>
/// CryptoSystem.cs:30-49 — is <paramref name="x"/> the current key, a
/// parked key, or reachable within the remaining search window? Walking
/// parks every skipped word. Verbatim port including the loop bound
/// being captured BEFORE the walk starts.
/// </summary>
public bool Search(uint x)
{
if (CurrentKey == x)
return true;
if (_xors.Contains(x))
return true;
int g = _xors.Count;
for (int i = 0; i < MaximumEffortLevel - g; i++)
{
_xors.Add(CurrentKey);
ConsumeKey(CurrentKey);
if (CurrentKey == x)
return true;
}
return false;
}
}