feat(net): N5 - loss observability, lossy decorator, the connected loss gate
Campaign N Slice N5 (docs/plans/2026-07-29-network-transport-campaign.md section 8 rung 3): the permanent removal of the loopback blindness that let #260 ship. Local ACE never drops a datagram, so every historical connected gate was structurally incapable of exercising the N1-N4 recovery machinery; from this slice on, tools/run-connected-loss-gate.ps1 runs the standard lifecycle route through deterministic seeded loss and passes only on proven non-zero recovery. Observability: - [net-tick] gains resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s reclaim/s cache= nakset= - TransportStats window deltas mirroring the acks/s cumulative-delta pattern, plus the two instantaneous depths (the unbounded-like-retail sent-packet cache watchdog and the inbound NAK set). TransportStats gains RejectsReceived (inbound RejectRetransmit packets). Counters increment unconditionally; every string is behind NetDiagnostics.ProbeNet (Code Structure Rule 5). - WorldSession.Dispose emits one cumulative [net-final] totals line so the loss gate asserts exact counters instead of reconstructing them from rounded per-second rates. - LinkStatusSnapshot.PacketLossPercentage is deliberately NOT wired: filed #261 - retail's CLinkStatusAverages formula (LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) must be located first; inventing a ratio is forbidden. N4-review F3 fold-in: - Fresh reliable sends stamp Header.Iteration = the session iteration through the same shared retail header build already cited for Time (N3) and the N4 control packets: FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84/0x00547AA8. The control-header rule now holds across all three send shapes (fresh reliable, ack, NAK). ACE reads neither Time nor Iteration inbound (campaign section 3) - wire-safe, and resends keep the stamp verbatim per the N1 rebuild rule. Loss injection (Transport/LossyTransportDecorator): - IWorldSessionTransport wrapper with deterministic seeded per-direction loss. Config via NetDiagnostics typed env properties read once: ACDREAM_NET_DROP_PCT (0 = off = default), ACDREAM_NET_DROP_SEED (default 1), ACDREAM_NET_DROP_DIR (out|in|both, default both). - Arming gate: NOTHING drops in either direction until the decorator has FORWARDED the first ENCRYPTED outbound datagram - parse-free check on length > 20 with EncryptedChecksum set in the LE flags word at bytes 4..8. The cleartext handshake always survives and the arming datagram is never a casualty; handshake-loss testing belongs to N6's ConnectResponse 0.333 s retransmit. - Structurally absent at 0%: WrapIfConfigured returns the raw transport - WorldSession's default factory is the only production seam and a normal run never constructs the decorator. Root-cause fix the gate immediately exposed: - The logoff-confirmation wait in Dispose processed inbound datagrams but never pumped the transport, so a lost S2C logoff confirmation was gap-detected but its healing NAK never went out. Retail's pump (Client::UseTime @ 0x00411C40 -> PacketController::UseTime @ 0x005410D0) runs until LogOffServer; the wait now sweeps per processed datagram, making the logoff wait the third covered blocking pump (after Tick and the handshake loops). A lost C2S logoff REQUEST remains unrecoverable by ACE design (arrival-driven NAK; a quiet client is never NAKed - campaign section 3 row 1), recorded in the gate header. Gates: - tools/run-connected-loss-gate.ps1 (-DropPct 2 -Seed 1): PASS vs local ACE - the first automated observation of packet loss in project history. Decorator ledger: dropped out=3 in=10 of forwarded out=183 in=496. [net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114 acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0 uncached-nak=0 cache=1 nakset=0. Every injected loss healed: both ACE-driven C2S resend recovery (nak-in=2 -> resends=2) and client-driven S2C NAK recovery (parked=9 -> nak-out=6) fired on a real connected route, all six checkpoints validated, graceful logout confirmed, ACE recorded the transport Disconnect. - tools/run-connected-world-lifecycle-gate.ps1 (decorator absent): PASS - zero behavior change on the no-loss baseline; the gate now defensively clears the drop env vars. - Core.Net Release: 747/747 (737 + 10 N5: decorator determinism/direction/ arming/structural-absence/env parsing, the 5% seeded WorldSession lossy lifecycle with zero message loss both ways + ACE Headroom 256, the [net-tick] field pins, the Iteration stamps). - Full solution Release: 9,763 passed / 5 skipped / 0 failed. Test-fixture note: FakeAceTransport gains AutoAdvanceOnBlockingReceive so virtual time can move during the blocking Connect()/EnterWorld() pumps - with the clock frozen there, a dropped handshake-window datagram could never be NAK-healed (a fixture artifact, not a transport property). Campaign section 9 ledger row added (SHA recorded at N6 kickoff). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
396838bb40
commit
4e290f00d8
14 changed files with 1629 additions and 27 deletions
|
|
@ -1,11 +1,23 @@
|
|||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Direction mask for the N5 <c>LossyTransportDecorator</c>
|
||||
/// (<c>ACDREAM_NET_DROP_DIR</c>): drop outbound datagrams, inbound
|
||||
/// datagrams, or both.
|
||||
/// </summary>
|
||||
public enum NetDropDirection
|
||||
{
|
||||
Out,
|
||||
In,
|
||||
Both,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic owner for the <c>ACDREAM_PROBE_NET</c> probe family (#260).
|
||||
/// Read once at startup, following the <c>PhysicsDiagnostics</c> pattern.
|
||||
///
|
||||
/// <para>
|
||||
/// When enabled, three probe line families are emitted:
|
||||
/// When enabled, four probe line families are emitted:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>[net-out]</c> — one line per outbound reliable game message at the
|
||||
/// <c>WorldSession.SendGameMessage</c> chokepoint: opcode, game-action type +
|
||||
|
|
@ -16,7 +28,16 @@ namespace AcDream.Core.Net;
|
|||
/// <item><c>[net-tick]</c> — a once-per-second cadence summary from
|
||||
/// <c>WorldSession.Tick</c>: inbound datagrams/s, remaining queue depth,
|
||||
/// budget-break count, worst inter-tick gap (= worst frame stall as seen by
|
||||
/// the net pump), outbound sends/s, and acks/s.</item>
|
||||
/// the net pump), outbound sends/s, acks/s, and — the N5 loss-observability
|
||||
/// extension — per-second reliable-transport rates (resends, NAKs out/in,
|
||||
/// rejects in, duplicate drops, parked keystream words, AD-51 reclaims)
|
||||
/// plus the two instantaneous depths (sent-packet cache, inbound NAK set).
|
||||
/// The counters themselves increment unconditionally in
|
||||
/// <c>TransportStats</c>; only the string work is gated here.</item>
|
||||
/// <item><c>[net-final]</c> — one cumulative <c>TransportStats</c> summary
|
||||
/// emitted at <c>WorldSession.Dispose</c>, so a connected gate (N5's loss
|
||||
/// gate) can assert exact totals instead of reconstructing them from
|
||||
/// rounded per-second rates.</item>
|
||||
/// <item><c>[cmd-gate]</c> — one line per generation-gated runtime command
|
||||
/// REJECTION in <c>CurrentGameRuntimeCommandAdapter.Validate</c> (status,
|
||||
/// expected vs view generation, lifecycle, IsInWorld) plus the combat-toggle
|
||||
|
|
@ -34,6 +55,50 @@ public static class NetDiagnostics
|
|||
public static bool ProbeNet { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_NET") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACDREAM_NET_DROP_PCT</c> (int 0–100, default 0 = off) — N5
|
||||
/// deterministic loss injection. When > 0 the session's DEFAULT
|
||||
/// transport factory wraps the socket transport in
|
||||
/// <c>LossyTransportDecorator</c>; at 0 the decorator is structurally
|
||||
/// absent (never constructed). Out-of-range or unparsable values read
|
||||
/// as 0 — a mistyped variable must never inject loss.
|
||||
/// </summary>
|
||||
public static int NetDropPercent { get; set; } =
|
||||
ParseDropPercent(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_NET_DROP_PCT"));
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACDREAM_NET_DROP_SEED</c> (int, default 1) — the decorator's PRNG
|
||||
/// seed. Same seed ⇒ identical per-direction drop pattern.
|
||||
/// </summary>
|
||||
public static int NetDropSeed { get; set; } =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_NET_DROP_SEED"),
|
||||
out int seed)
|
||||
? seed
|
||||
: 1;
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACDREAM_NET_DROP_DIR</c> (<c>out</c> | <c>in</c> | <c>both</c>,
|
||||
/// default <c>both</c>) — which directions the decorator drops.
|
||||
/// </summary>
|
||||
public static NetDropDirection NetDropDir { get; set; } =
|
||||
ParseDropDirection(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_NET_DROP_DIR"));
|
||||
|
||||
internal static int ParseDropPercent(string? value) =>
|
||||
int.TryParse(value, out int percent) && percent is >= 0 and <= 100
|
||||
? percent
|
||||
: 0;
|
||||
|
||||
internal static NetDropDirection ParseDropDirection(string? value) =>
|
||||
value?.ToLowerInvariant() switch
|
||||
{
|
||||
"out" => NetDropDirection.Out,
|
||||
"in" => NetDropDirection.In,
|
||||
_ => NetDropDirection.Both,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACDREAM_PROBE_REVEAL=1</c> — #260 reveal-stall probe: while a
|
||||
/// reveal destination's composite warmup is incomplete, emit one
|
||||
|
|
|
|||
234
src/AcDream.Core.Net/Transport/LossyTransportDecorator.cs
Normal file
234
src/AcDream.Core.Net/Transport/LossyTransportDecorator.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N5: deterministic seeded packet-loss injection around a
|
||||
/// real <see cref="IWorldSessionTransport"/> — the permanent removal of the
|
||||
/// loopback blindness that let #260 ship (local ACE never drops a datagram,
|
||||
/// so every historical connected gate was structurally incapable of
|
||||
/// exercising the N1–N4 recovery machinery). The connected loss gate
|
||||
/// (<c>tools/run-connected-loss-gate.ps1</c>) runs the standard lifecycle
|
||||
/// route through this decorator and passes only when the transport counters
|
||||
/// prove real loss was injected AND healed.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Configuration</b> comes from the <see cref="NetDiagnostics"/> typed
|
||||
/// env-var owner (Code Structure Rule 5 — read once at startup):
|
||||
/// <c>ACDREAM_NET_DROP_PCT</c> (0 = off), <c>ACDREAM_NET_DROP_SEED</c>, and
|
||||
/// <c>ACDREAM_NET_DROP_DIR</c>. <see cref="WrapIfConfigured"/> is the ONLY
|
||||
/// production entry point, and it returns the inner transport untouched when
|
||||
/// the percentage is zero — a normal run never constructs the decorator
|
||||
/// (structural absence, not an inert wrapper).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The arming gate:</b> nothing is dropped in either direction until the
|
||||
/// decorator has FORWARDED the first ENCRYPTED outbound datagram — detected
|
||||
/// parse-free as length > 20 with <see
|
||||
/// cref="PacketHeaderFlags.EncryptedChecksum"/> set in the little-endian
|
||||
/// flags word at bytes 4..8. That first encrypted send only exists after
|
||||
/// ISAAC negotiation, so the cleartext handshake (LoginRequest →
|
||||
/// ConnectRequest → ConnectResponse) always completes intact and the arming
|
||||
/// datagram itself is never dropped. Handshake-loss testing is deliberately
|
||||
/// out of scope here — it belongs to N6's ConnectResponse 0.333 s
|
||||
/// retransmit (campaign doc §6).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Determinism:</b> one <see cref="Random"/> per direction (outbound
|
||||
/// seeded with <c>seed</c>, inbound with <c>~seed</c> so the two streams
|
||||
/// differ), consumed only for droppable datagrams (post-arming, direction
|
||||
/// enabled), so a given seed yields an identical per-direction drop pattern
|
||||
/// for a given datagram sequence. Threading matches the transport contract:
|
||||
/// sends happen on the session's frame thread, receives on the single
|
||||
/// receive owner, so each PRNG is single-consumer; only the arming latch
|
||||
/// crosses the two.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class LossyTransportDecorator : IWorldSessionTransport
|
||||
{
|
||||
private readonly IWorldSessionTransport _inner;
|
||||
private readonly int _dropPercent;
|
||||
private readonly Random _outboundRandom;
|
||||
private readonly Random _inboundRandom;
|
||||
private readonly bool _dropOutbound;
|
||||
private readonly bool _dropInbound;
|
||||
private volatile bool _armed;
|
||||
|
||||
private int _outboundDropped;
|
||||
private int _inboundDropped;
|
||||
private int _outboundForwarded;
|
||||
private int _inboundForwarded;
|
||||
|
||||
/// <summary>Outbound datagrams eaten so far (diagnostic evidence for the
|
||||
/// loss gate's "the decorator actually dropped" assertion).</summary>
|
||||
public int OutboundDropped => Volatile.Read(ref _outboundDropped);
|
||||
|
||||
/// <summary>Inbound datagrams eaten so far.</summary>
|
||||
public int InboundDropped => Volatile.Read(ref _inboundDropped);
|
||||
|
||||
/// <summary>True once the first encrypted outbound datagram has been
|
||||
/// forwarded (the arming gate above).</summary>
|
||||
public bool IsArmed => _armed;
|
||||
|
||||
public LossyTransportDecorator(
|
||||
IWorldSessionTransport inner,
|
||||
int dropPercent,
|
||||
int seed,
|
||||
NetDropDirection direction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inner);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(dropPercent, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(dropPercent, 100);
|
||||
|
||||
_inner = inner;
|
||||
_dropPercent = dropPercent;
|
||||
_outboundRandom = new Random(seed);
|
||||
_inboundRandom = new Random(~seed);
|
||||
_dropOutbound = direction is NetDropDirection.Out or NetDropDirection.Both;
|
||||
_dropInbound = direction is NetDropDirection.In or NetDropDirection.Both;
|
||||
Console.WriteLine(
|
||||
$"[net-loss] active pct={dropPercent} seed={seed} dir={direction}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The production seam: wrap <paramref name="inner"/> only when
|
||||
/// <see cref="NetDiagnostics.NetDropPercent"/> is non-zero. At zero the
|
||||
/// decorator is never constructed — the default transport path is
|
||||
/// byte-for-byte the pre-N5 one.
|
||||
/// </summary>
|
||||
public static IWorldSessionTransport WrapIfConfigured(
|
||||
IWorldSessionTransport inner) =>
|
||||
NetDiagnostics.NetDropPercent > 0
|
||||
? new LossyTransportDecorator(
|
||||
inner,
|
||||
NetDiagnostics.NetDropPercent,
|
||||
NetDiagnostics.NetDropSeed,
|
||||
NetDiagnostics.NetDropDir)
|
||||
: inner;
|
||||
|
||||
// ---- outbound ----
|
||||
|
||||
public void Send(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
if (DropOutbound())
|
||||
return;
|
||||
_inner.Send(datagram);
|
||||
AfterOutboundForwarded(datagram);
|
||||
}
|
||||
|
||||
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
if (DropOutbound())
|
||||
return;
|
||||
_inner.Send(remote, datagram);
|
||||
AfterOutboundForwarded(datagram);
|
||||
}
|
||||
|
||||
private bool DropOutbound()
|
||||
{
|
||||
if (!_armed || !_dropOutbound)
|
||||
return false;
|
||||
if (_outboundRandom.Next(100) >= _dropPercent)
|
||||
return false;
|
||||
Interlocked.Increment(ref _outboundDropped);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AfterOutboundForwarded(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
Interlocked.Increment(ref _outboundForwarded);
|
||||
if (_armed)
|
||||
return;
|
||||
// The arming gate: bytes 4..8 are the little-endian
|
||||
// PacketHeaderFlags word of the fixed 20-byte header. The first
|
||||
// forwarded datagram carrying EncryptedChecksum proves negotiation
|
||||
// completed; from the NEXT datagram on, drops are live.
|
||||
if (datagram.Length > PacketHeader.Size
|
||||
&& (BinaryPrimitives.ReadUInt32LittleEndian(datagram.Slice(4))
|
||||
& (uint)PacketHeaderFlags.EncryptedChecksum) != 0)
|
||||
{
|
||||
_armed = true;
|
||||
Console.WriteLine("[net-loss] armed");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
public int Receive(
|
||||
Span<byte> destination,
|
||||
TimeSpan timeout,
|
||||
out IPEndPoint? from)
|
||||
{
|
||||
// The first inner call gets the caller's timeout verbatim, so the
|
||||
// no-drop path is behavior-identical to the undecorated transport.
|
||||
// Only a drop re-enters the loop with the remaining time.
|
||||
long deadline = Stopwatch.GetTimestamp()
|
||||
+ (long)(timeout.TotalSeconds * Stopwatch.Frequency);
|
||||
TimeSpan next = timeout;
|
||||
while (true)
|
||||
{
|
||||
int length = _inner.Receive(destination, next, out from);
|
||||
if (length < 0)
|
||||
return length; // inner timeout contract: -1
|
||||
if (!DropInbound())
|
||||
{
|
||||
Interlocked.Increment(ref _inboundForwarded);
|
||||
return length;
|
||||
}
|
||||
|
||||
double remainingMs =
|
||||
(deadline - Stopwatch.GetTimestamp())
|
||||
* 1000.0 / Stopwatch.Frequency;
|
||||
if (remainingMs < 1.0)
|
||||
{
|
||||
// Expired while eating datagrams. NetClient treats a 0 ms
|
||||
// socket timeout as INFINITE, so never pass ≤ 0 back down.
|
||||
from = null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
next = TimeSpan.FromMilliseconds(remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
NetReceiveResult result = await _inner
|
||||
.ReceiveAsync(destination, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (DropInbound())
|
||||
continue;
|
||||
Interlocked.Increment(ref _inboundForwarded);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private bool DropInbound()
|
||||
{
|
||||
if (!_armed || !_dropInbound)
|
||||
return false;
|
||||
if (_inboundRandom.Next(100) >= _dropPercent)
|
||||
return false;
|
||||
Interlocked.Increment(ref _inboundDropped);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[net-loss] dropped out={OutboundDropped} in={InboundDropped}"
|
||||
+ $" forwarded out={Volatile.Read(ref _outboundForwarded)}"
|
||||
+ $" in={Volatile.Read(ref _inboundForwarded)}"
|
||||
+ $" armed={_armed}");
|
||||
_inner.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
private readonly SentPacketStore _store;
|
||||
private readonly ArrayPool<byte> _pool;
|
||||
private readonly ushort _sessionClientId;
|
||||
private readonly ushort _sessionIteration;
|
||||
|
||||
/// <summary>Wrap-safe sorted pending NAKed ids awaiting the next sweep
|
||||
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c> merge-insert).</summary>
|
||||
|
|
@ -83,6 +84,7 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
public OutboundFlowQueue(
|
||||
IsaacRandom outboundIsaac,
|
||||
ushort sessionClientId,
|
||||
ushort sessionIteration,
|
||||
TransportClock clock,
|
||||
TransportStats stats,
|
||||
DatagramSendDelegate send,
|
||||
|
|
@ -97,6 +99,7 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
|
||||
_outboundIsaac = outboundIsaac;
|
||||
_sessionClientId = sessionClientId;
|
||||
_sessionIteration = sessionIteration;
|
||||
_clock = clock;
|
||||
_stats = stats;
|
||||
_send = send;
|
||||
|
|
@ -118,12 +121,16 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
/// it, THEN cache it (retail commits to the sent-packet store only after
|
||||
/// a successful send — <c>FlowQueue::TransmitNewPackets @ 0x00547C85</c>).
|
||||
/// Flags <c>BlobFragments|EncryptedChecksum</c>, session client id, one
|
||||
/// ISAAC word, and — the N3 fold-in of the N1 review advisory —
|
||||
/// <c>Time</c> = the current interval id: retail stamps
|
||||
/// <c>CurLocalInterval_.intervalID_</c> on every fresh packet
|
||||
/// (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, the header build
|
||||
/// at 0x00547A84). ACE never reads inbound <c>Header.Time</c>
|
||||
/// (campaign §3), so wire compatibility is unaffected.
|
||||
/// ISAAC word, and the retail control-header rule completed across all
|
||||
/// three send shapes (fresh reliable here; ack + NAK in
|
||||
/// <see cref="AckNakScheduler"/>): <c>Time</c> = the current interval id
|
||||
/// (the N3 fold-in of the N1 review advisory) and <c>Iteration</c> = the
|
||||
/// session iteration (the N5 fold-in of the N4 review advisory F3).
|
||||
/// Retail stamps both through one shared header build
|
||||
/// (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, the stack build
|
||||
/// at 0x00547A84/0x00547AA8 — <c>CurLocalInterval_.intervalID_</c> plus
|
||||
/// the receiver iteration). ACE reads neither field inbound (campaign
|
||||
/// §3), so wire compatibility is unaffected.
|
||||
/// </summary>
|
||||
public void SendGameMessage(
|
||||
ReadOnlySpan<byte> gameMessageBody,
|
||||
|
|
@ -147,6 +154,7 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
| PacketHeaderFlags.EncryptedChecksum,
|
||||
Id = _sessionClientId,
|
||||
Time = _clock.IntervalId,
|
||||
Iteration = _sessionIteration,
|
||||
};
|
||||
int datagramLength = PacketCodec.FinalizeInPlace(
|
||||
header,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ internal sealed class ReliableTransport : IDisposable
|
|||
Outbound = new OutboundFlowQueue(
|
||||
outboundIsaac,
|
||||
sessionClientId,
|
||||
sessionIteration,
|
||||
Clock,
|
||||
Stats,
|
||||
send,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ internal sealed class TransportStats
|
|||
/// adaptation; always zero against a retail server).</summary>
|
||||
public long RejectWordsReclaimed;
|
||||
|
||||
/// <summary>N5: inbound packets carrying <c>RejectRetransmit</c> — the
|
||||
/// server abandoned ids we NAKed (its cache pruned them). The
|
||||
/// <c>rej-in/s</c> field of <c>[net-tick]</c>.</summary>
|
||||
public long RejectsReceived;
|
||||
|
||||
/// <summary>Live sent-packet cache depth — the N5 watchdog value
|
||||
/// (<c>cache=N</c> in <c>[net-tick]</c>; the cache is unbounded like
|
||||
/// retail's, so depth is the health signal, not a cap).</summary>
|
||||
|
|
|
|||
|
|
@ -67,10 +67,13 @@ internal sealed class NetClientWorldSessionTransport(IPEndPoint remote)
|
|||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Still deferred:</b> inbound sequence-aligned ISAAC + client NAK
|
||||
/// emission (Campaign N slices N2/N4) and unsolicited-disconnect recovery.
|
||||
/// The outbound sent-packet cache + resend on server NAK (N1), ACKs, world
|
||||
/// updates, chat, and retail-ordered graceful logout are live.
|
||||
/// <b>Still deferred:</b> unsolicited-disconnect recovery and the optional
|
||||
/// N6 handshake hardening (ConnectResponse 0.333 s retransmit). The full
|
||||
/// Campaign N reliable transport is live in both directions: outbound
|
||||
/// sent-packet cache + resend on server NAK (N1), inbound sequence-aligned
|
||||
/// ISAAC + NAK set (N2), the retail 2.0 s cumulative-ack sweep (N3), client
|
||||
/// NAK emission + RejectRetransmit reclaim (N4), and the N5 loss
|
||||
/// observability + deterministic loss injection seam.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldSession : IDisposable
|
||||
|
|
@ -761,7 +764,13 @@ public sealed class WorldSession : IDisposable
|
|||
public WorldSession(IPEndPoint serverLogin)
|
||||
: this(
|
||||
serverLogin,
|
||||
static endpoint => new NetClientWorldSessionTransport(endpoint))
|
||||
// N5: ACDREAM_NET_DROP_PCT > 0 wraps the socket transport in the
|
||||
// deterministic LossyTransportDecorator (the connected loss
|
||||
// gate's injection point). At the default 0 the decorator is
|
||||
// structurally absent — WrapIfConfigured returns the raw
|
||||
// transport and never constructs the wrapper.
|
||||
static endpoint => LossyTransportDecorator.WrapIfConfigured(
|
||||
new NetClientWorldSessionTransport(endpoint)))
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -1124,6 +1133,16 @@ public sealed class WorldSession : IDisposable
|
|||
private int _probeBudgetBreaks;
|
||||
private int _probeSendWindow;
|
||||
private long _probeAckSeenTotal;
|
||||
// N5 loss-observability window baselines — the cumulative TransportStats
|
||||
// value each counter had when the probe last printed, mirroring
|
||||
// _probeAckSeenTotal. Only touched when NetDiagnostics.ProbeNet is set.
|
||||
private long _probeResendSeenTotal;
|
||||
private long _probeNakOutSeenTotal;
|
||||
private long _probeNakInSeenTotal;
|
||||
private long _probeRejInSeenTotal;
|
||||
private long _probeDupDropSeenTotal;
|
||||
private long _probeParkedSeenTotal;
|
||||
private long _probeReclaimSeenTotal;
|
||||
// Probe-owned queue depth: the SingleReader channel's Reader.Count
|
||||
// throws NotSupportedException, so the net thread increments on
|
||||
// enqueue and the frame thread decrements on dequeue instead.
|
||||
|
|
@ -1158,23 +1177,100 @@ public sealed class WorldSession : IDisposable
|
|||
double windowSeconds = (double)windowTicks / Stopwatch.Frequency;
|
||||
double maxGapMs = _probeMaxGapTicks * 1000.0 / Stopwatch.Frequency;
|
||||
int sends = Interlocked.Exchange(ref _probeSendWindow, 0);
|
||||
long ackTotal = _transport?.Stats.AcksSent ?? 0;
|
||||
long acks = ackTotal - _probeAckSeenTotal;
|
||||
_probeAckSeenTotal = ackTotal;
|
||||
Console.WriteLine(
|
||||
$"[net-tick] in/s={_probeProcessedWindow / windowSeconds:F0}"
|
||||
+ $" q={Volatile.Read(ref _probeInboundDepth)}"
|
||||
+ $" budget-breaks={_probeBudgetBreaks}"
|
||||
+ $" maxgap={maxGapMs:F0}ms"
|
||||
+ $" out/s={sends / windowSeconds:F0}"
|
||||
+ $" acks/s={acks / windowSeconds:F0}"
|
||||
+ $" st={CurrentState}");
|
||||
ReliableTransport? transport = _transport;
|
||||
TransportStats? stats = transport?.Stats;
|
||||
long acks = WindowDelta(stats?.AcksSent ?? 0, ref _probeAckSeenTotal);
|
||||
// N5: reliable-transport window deltas (same cumulative-delta shape
|
||||
// as acks/s) + the two instantaneous depths. The counters increment
|
||||
// unconditionally in TransportStats; only this string work is
|
||||
// probe-gated.
|
||||
long resends = WindowDelta(
|
||||
stats?.ResendsSent ?? 0, ref _probeResendSeenTotal);
|
||||
long naksOut = WindowDelta(
|
||||
stats?.NaksSent ?? 0, ref _probeNakOutSeenTotal);
|
||||
long naksIn = WindowDelta(
|
||||
stats?.NakRequestsReceived ?? 0, ref _probeNakInSeenTotal);
|
||||
long rejsIn = WindowDelta(
|
||||
stats?.RejectsReceived ?? 0, ref _probeRejInSeenTotal);
|
||||
long dupDrops = WindowDelta(
|
||||
stats?.InboundDupsDropped ?? 0, ref _probeDupDropSeenTotal);
|
||||
long parked = WindowDelta(
|
||||
stats?.KeysParked ?? 0, ref _probeParkedSeenTotal);
|
||||
long reclaimed = WindowDelta(
|
||||
stats?.RejectWordsReclaimed ?? 0, ref _probeReclaimSeenTotal);
|
||||
Console.WriteLine(FormatNetTickLine(
|
||||
windowSeconds,
|
||||
_probeProcessedWindow,
|
||||
Volatile.Read(ref _probeInboundDepth),
|
||||
_probeBudgetBreaks,
|
||||
maxGapMs,
|
||||
sends,
|
||||
acks,
|
||||
resends,
|
||||
naksOut,
|
||||
naksIn,
|
||||
rejsIn,
|
||||
dupDrops,
|
||||
parked,
|
||||
reclaimed,
|
||||
stats?.CacheDepth ?? 0,
|
||||
transport?.Inbound.NakCount ?? 0,
|
||||
CurrentState));
|
||||
_probeWindowStartTs = tickStartTs;
|
||||
_probeMaxGapTicks = 0;
|
||||
_probeProcessedWindow = 0;
|
||||
_probeBudgetBreaks = 0;
|
||||
}
|
||||
|
||||
private static long WindowDelta(long cumulative, ref long seenTotal)
|
||||
{
|
||||
long delta = cumulative - seenTotal;
|
||||
seenTotal = cumulative;
|
||||
return delta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>[net-tick]</c> line shape, extracted so the N5 field extension
|
||||
/// is string-assertable without a wall-clock window. <c>cache</c> and
|
||||
/// <c>nakset</c> are instantaneous depths (the sent-packet cache is the
|
||||
/// unbounded-like-retail watchdog value — campaign §4); every other
|
||||
/// transport field is a per-second rate over the probe window.
|
||||
/// </summary>
|
||||
internal static string FormatNetTickLine(
|
||||
double windowSeconds,
|
||||
int processed,
|
||||
int queueDepth,
|
||||
int budgetBreaks,
|
||||
double maxGapMs,
|
||||
int sends,
|
||||
long acks,
|
||||
long resends,
|
||||
long naksOut,
|
||||
long naksIn,
|
||||
long rejsIn,
|
||||
long dupDrops,
|
||||
long parked,
|
||||
long reclaimed,
|
||||
int cacheDepth,
|
||||
int nakSetDepth,
|
||||
State state) =>
|
||||
$"[net-tick] in/s={processed / windowSeconds:F0}"
|
||||
+ $" q={queueDepth}"
|
||||
+ $" budget-breaks={budgetBreaks}"
|
||||
+ $" maxgap={maxGapMs:F0}ms"
|
||||
+ $" out/s={sends / windowSeconds:F0}"
|
||||
+ $" acks/s={acks / windowSeconds:F0}"
|
||||
+ $" resend/s={resends / windowSeconds:F0}"
|
||||
+ $" nak-out/s={naksOut / windowSeconds:F0}"
|
||||
+ $" nak-in/s={naksIn / windowSeconds:F0}"
|
||||
+ $" rej-in/s={rejsIn / windowSeconds:F0}"
|
||||
+ $" dup-drop/s={dupDrops / windowSeconds:F0}"
|
||||
+ $" parked/s={parked / windowSeconds:F0}"
|
||||
+ $" reclaim/s={reclaimed / windowSeconds:F0}"
|
||||
+ $" cache={cacheDepth}"
|
||||
+ $" nakset={nakSetDepth}"
|
||||
+ $" st={state}";
|
||||
|
||||
/// <summary>
|
||||
/// Pure, testable decision for the per-frame inbound bound: stop draining only when
|
||||
/// in-world AND the elapsed Stopwatch ticks have reached the budget. Extracted so the
|
||||
|
|
@ -1446,6 +1542,7 @@ public sealed class WorldSession : IDisposable
|
|||
// consumed-in-place.
|
||||
if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0)
|
||||
{
|
||||
transport.Stats.RejectsReceived++;
|
||||
if (packet.Optional.RejectRetransmitCount > 0)
|
||||
{
|
||||
transport.Inbound.OnRejectRetransmit(
|
||||
|
|
@ -2569,6 +2666,29 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
_netCancel.Dispose();
|
||||
|
||||
// N5: one cumulative TransportStats summary so the connected loss
|
||||
// gate asserts exact totals instead of reconstructing them from the
|
||||
// rounded per-second [net-tick] rates.
|
||||
if (NetDiagnostics.ProbeNet && _transport is { } finalTransport)
|
||||
{
|
||||
TransportStats finalStats = finalTransport.Stats;
|
||||
Console.WriteLine(
|
||||
$"[net-final] resends={finalStats.ResendsSent}"
|
||||
+ $" nak-in={finalStats.NakRequestsReceived}"
|
||||
+ $" nak-out={finalStats.NaksSent}"
|
||||
+ $" rej-in={finalStats.RejectsReceived}"
|
||||
+ $" acks-out={finalStats.AcksSent}"
|
||||
+ $" acks-in={finalStats.AcksConsumed}"
|
||||
+ $" dup-drop={finalStats.InboundDupsDropped}"
|
||||
+ $" sanity-drop={finalStats.InboundSanityDrops}"
|
||||
+ $" cksum-fail={finalStats.ChecksumFailures}"
|
||||
+ $" parked={finalStats.KeysParked}"
|
||||
+ $" reclaimed={finalStats.RejectWordsReclaimed}"
|
||||
+ $" uncached-nak={finalStats.UncachedNakIds}"
|
||||
+ $" cache={finalStats.CacheDepth}"
|
||||
+ $" nakset={finalTransport.Inbound.NakCount}");
|
||||
}
|
||||
|
||||
// N1: return every rented sent-packet cache buffer before the
|
||||
// socket goes away.
|
||||
_transport?.Dispose();
|
||||
|
|
@ -2674,6 +2794,23 @@ public sealed class WorldSession : IDisposable
|
|||
ProcessDatagram(
|
||||
datagram.Memory,
|
||||
dispatchWorldEvents: false);
|
||||
// N5: keep the transport pumped while waiting for the
|
||||
// logoff confirmation — retail's frame pump
|
||||
// (Client::UseTime @ 0x00411C40 →
|
||||
// PacketController::UseTime @ 0x005410D0) keeps running
|
||||
// until LogOffServer, so the logoff wait is the third
|
||||
// blocking pump the sweep must cover (after Tick and the
|
||||
// handshake loops). The connected loss gate exposed the
|
||||
// gap: without a sweep here, a lost S2C confirmation can
|
||||
// be gap-detected (ACE's next sequenced packet arrives and
|
||||
// parks a key) but the NAK that would heal it never goes
|
||||
// out, and the graceful logout dies at the 35 s timeout.
|
||||
// ACE's 2 s ack cadence guarantees arrivals to hang this
|
||||
// callback on. (A lost C2S logoff REQUEST remains
|
||||
// unrecoverable against ACE — its NAK is arrival-driven
|
||||
// and a quiet client is never NAKed, campaign §3 row 1 —
|
||||
// the same idle-tail constraint the N4 soak recorded.)
|
||||
SweepTransport();
|
||||
return Volatile.Read(ref _characterLogOffConfirmed) != 0;
|
||||
},
|
||||
ReturnInboundDatagram);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue