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>
168 lines
5.8 KiB
C#
168 lines
5.8 KiB
C#
namespace AcDream.Core.Net.Tests.Transport;
|
|
|
|
internal enum LinkDirection
|
|
{
|
|
ClientToServer,
|
|
ServerToClient,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic datagram fault injector between two endpoints. Pure data
|
|
/// structure — no sockets, no threads, no wall clock. Callers push each
|
|
/// datagram through <see cref="Transmit"/> and deliver whatever comes back,
|
|
/// in order.
|
|
///
|
|
/// <para>
|
|
/// Fault evaluation order per datagram (first match wins):
|
|
/// scheduled per-index drops (<see cref="DropAt"/>) → one-shot drop budget
|
|
/// (<see cref="DropNext"/>) → persistent predicates (<see cref="Drop"/>) →
|
|
/// seeded random loss (<see cref="RandomLoss"/>). A surviving datagram is
|
|
/// then subject to an armed <see cref="Reorder"/>: the next survivor is held
|
|
/// back and delivered immediately AFTER the survivor that follows it
|
|
/// (adjacent swap). Datagram bytes are copied on entry, so callers may pass
|
|
/// stack-allocated spans.
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class LossyLink
|
|
{
|
|
private sealed class DirectionState
|
|
{
|
|
public int TransmitIndex;
|
|
public int PendingDropCount;
|
|
public readonly HashSet<int> DropIndices = new();
|
|
public readonly List<Func<int, byte[], bool>> DropPredicates = new();
|
|
public Random? LossRandom;
|
|
public double LossProbability;
|
|
public bool ReorderArmed;
|
|
public byte[]? Held;
|
|
public int Dropped;
|
|
public int Delivered;
|
|
}
|
|
|
|
private readonly DirectionState _clientToServer = new();
|
|
private readonly DirectionState _serverToClient = new();
|
|
|
|
private DirectionState State(LinkDirection direction) =>
|
|
direction == LinkDirection.ClientToServer ? _clientToServer : _serverToClient;
|
|
|
|
/// <summary>Drop the next <paramref name="count"/> datagrams in <paramref name="direction"/>.</summary>
|
|
public void DropNext(LinkDirection direction, int count = 1)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegative(count);
|
|
State(direction).PendingDropCount += count;
|
|
}
|
|
|
|
/// <summary>Drop the datagram with the given per-direction transmit index (0-based).</summary>
|
|
public void DropAt(LinkDirection direction, int transmitIndex) =>
|
|
State(direction).DropIndices.Add(transmitIndex);
|
|
|
|
/// <summary>
|
|
/// Drop every datagram matching <paramref name="predicate"/> (persistent;
|
|
/// receives the per-direction transmit index and the datagram bytes).
|
|
/// </summary>
|
|
public void Drop(LinkDirection direction, Func<int, byte[], bool> predicate) =>
|
|
State(direction).DropPredicates.Add(predicate);
|
|
|
|
/// <summary>
|
|
/// Swap the next two surviving datagrams: the next survivor is held and
|
|
/// released right after the survivor that follows it.
|
|
/// </summary>
|
|
public void Reorder(LinkDirection direction) =>
|
|
State(direction).ReorderArmed = true;
|
|
|
|
/// <summary>
|
|
/// Enable seeded random loss: each surviving datagram is dropped with
|
|
/// <paramref name="probability"/> using <c>Random(seed)</c> — fully
|
|
/// deterministic for a given seed + transmit sequence.
|
|
/// </summary>
|
|
public void RandomLoss(LinkDirection direction, double probability, int seed)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegative(probability);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(probability, 1.0);
|
|
DirectionState state = State(direction);
|
|
state.LossProbability = probability;
|
|
state.LossRandom = new Random(seed);
|
|
}
|
|
|
|
public int TransmitCount(LinkDirection direction) => State(direction).TransmitIndex;
|
|
public int DroppedCount(LinkDirection direction) => State(direction).Dropped;
|
|
public int DeliveredCount(LinkDirection direction) => State(direction).Delivered;
|
|
|
|
/// <summary>
|
|
/// Push one datagram through the link. Returns the datagrams to deliver
|
|
/// now, in order (0, 1, or 2 entries — 2 when a held reordered datagram
|
|
/// is released).
|
|
/// </summary>
|
|
public IReadOnlyList<byte[]> Transmit(LinkDirection direction, ReadOnlySpan<byte> datagram)
|
|
{
|
|
DirectionState state = State(direction);
|
|
int index = state.TransmitIndex++;
|
|
byte[] copy = datagram.ToArray();
|
|
|
|
bool drop = state.DropIndices.Remove(index);
|
|
if (!drop && state.PendingDropCount > 0)
|
|
{
|
|
state.PendingDropCount--;
|
|
drop = true;
|
|
}
|
|
|
|
if (!drop)
|
|
{
|
|
foreach (Func<int, byte[], bool> predicate in state.DropPredicates)
|
|
{
|
|
if (predicate(index, copy))
|
|
{
|
|
drop = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!drop
|
|
&& state.LossRandom is not null
|
|
&& state.LossRandom.NextDouble() < state.LossProbability)
|
|
{
|
|
drop = true;
|
|
}
|
|
|
|
if (drop)
|
|
{
|
|
state.Dropped++;
|
|
return Array.Empty<byte[]>();
|
|
}
|
|
|
|
if (state.ReorderArmed)
|
|
{
|
|
state.ReorderArmed = false;
|
|
state.Held = copy;
|
|
return Array.Empty<byte[]>();
|
|
}
|
|
|
|
if (state.Held is not null)
|
|
{
|
|
byte[] held = state.Held;
|
|
state.Held = null;
|
|
state.Delivered += 2;
|
|
return new[] { copy, held };
|
|
}
|
|
|
|
state.Delivered++;
|
|
return new[] { copy };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force-release a datagram held by <see cref="Reorder"/> that nothing
|
|
/// followed (it would otherwise be stuck forever). Returns the held
|
|
/// datagram or an empty list.
|
|
/// </summary>
|
|
public IReadOnlyList<byte[]> DrainHeld(LinkDirection direction)
|
|
{
|
|
DirectionState state = State(direction);
|
|
if (state.Held is null)
|
|
return Array.Empty<byte[]>();
|
|
byte[] held = state.Held;
|
|
state.Held = null;
|
|
state.Delivered++;
|
|
return new[] { held };
|
|
}
|
|
}
|