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:
Erik 2026-07-29 16:26:06 +02:00
parent 396838bb40
commit 4e290f00d8
14 changed files with 1629 additions and 27 deletions

View file

@ -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 0100, default 0 = off) — N5
/// deterministic loss injection. When &gt; 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

View 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 N1N4 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 &gt; 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();
}
}

View file

@ -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,

View file

@ -54,6 +54,7 @@ internal sealed class ReliableTransport : IDisposable
Outbound = new OutboundFlowQueue(
outboundIsaac,
sessionClientId,
sessionIteration,
Clock,
Stats,
send,

View file

@ -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>

View file

@ -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);