acdream/tests/AcDream.Core.Net.Tests/Transport/AceCryptoModel.cs
Erik 7e9134b4d1 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>
2026-07-29 11:07:13 +02:00

112 lines
4.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}