acdream/src/AcDream.Core.Net/NetDiagnostics.cs
Erik 4e290f00d8 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>
2026-07-29 16:26:06 +02:00

113 lines
4.9 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.

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, 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 +
/// sequence (when the body is a 0xF7B1 GameAction), fragment/packet sequence,
/// managed thread id, and session state. An exception escaping the wire write
/// additionally emits <c>[net-out-EX]</c> via an exception filter (log
/// without catching — behavior is unchanged).</item>
/// <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, 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
/// result. These rejections are otherwise completely silent, which is what
/// let #260's wedge hide.</item>
/// </list>
/// Zero steady-state cost when off: every site is behind this single bool.
/// </para>
/// </summary>
public static class NetDiagnostics
{
/// <summary>
/// <c>ACDREAM_PROBE_NET=1</c> — #260 outbound/command-gate probe family.
/// </summary>
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
/// <c>[composite-warmup]</c> line per second naming the pending queue
/// depth, scan state, upload-budget gate, and the first few unresolved
/// GfxObj ids. The two permanent-stall shapes (a mesh id that never
/// resolves vs. an upload budget that never reopens) are otherwise
/// indistinguishable and invisible.
/// </summary>
public static bool ProbeReveal { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL") == "1";
}