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>
112 lines
4.2 KiB
C#
112 lines
4.2 KiB
C#
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;
|
||
}
|
||
}
|