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>
113 lines
4.9 KiB
C#
113 lines
4.9 KiB
C#
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 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
|
||
/// <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";
|
||
}
|