From 852a59e3889f46aba405876e61f1d05e8255dd1f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 15:20:35 +0200 Subject: [PATCH] feat(net): N4 - client NAK emission + RejectRetransmit reclaim Campaign N slice N4 completes the AckNakScheduler NAK branch and closes the ACE cleartext-reject keystream hazard - the slice that makes S2C loss actually RECOVER. NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0): - One cleartext exact-flags RequestRetransmit per sweep behind the STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s and vice versa (landmine #7). - Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E); header Sequence borrowed from highestIDSent_ without incrementing; cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) - and a NAK never refreshes ACE's 60 s timeout. - Control-header rule decided once for BOTH ack and NAK: Time = the interval id, Iteration = the session iteration, matching retail's shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84). ACE reads neither field inbound. - Gate ticks now round instead of truncate: 0.6 has no exact double form, and truncation opened the strict gate exactly AT the boundary. RejectRetransmit reclaim (divergence register AD-51, ACE adaptation): - ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748) - the one place ACE breaks retail's gap-walk invariant that every missing id was word-bearing (retail cleartext always borrows live sequences). Unhandled, the gap walk parks a word for the reject's id and the inbound stream runs permanently one word ahead - the N2 desync class reintroduced through the reject path. - Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes the mis-park, shifts every later-drawn parked word down one position (per-word draw ordinals; ascending wrap-safe id <=> ascending draw order), and pools the excess word, consumed lowest-draw-order-first ahead of fresh ISAAC draws. Exact for any number of interleaved rejects in ANY arrival order - a plain reclaim FIFO is not: a reject arriving after a higher encrypted arrival crosses the parked chain, and two out-of-order rejects pool their excess words out of draw order (both orderings pinned by tests). - Reject BODY ids keep N2's discard: word-bearing server-side, consumed-in-place. The pool is provably empty against retail servers. N3 advisories folded (all five): honest transitional-state wording (the empty N3 NAK branch could silently disconnect a loopback session at ACE's 60 s timeout, witness [net-tick] acks/s=0), the ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0) citation, the FlowQueue::Empty pump-order wording (TransmitNaks -> TransmitAcks -> TransmitNewPackets with the interval increment LAST @ 0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the Time/Iteration rule above, and the stale WorldSession budget-break comment rewritten to the sweep reality. Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3 pins): strict-gate boundary, shared timestamp both directions, NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served retransmission round trip, five tracker reclaim proofs, the 130 s virtual prune -> fresh-sequence reject system test (victim abandoned, later traffic decodes, pool drains to zero), 10 s long-loss survival (NAKs on the gate cadence, zero acks, heal inside the window), and the capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero message loss both ways, ACE crypto headroom 256 at convergence, every ledger drained (cache at the single watermark entry - retail's Flush prunes STRICTLY below the ack). Full solution Release: 9,758 passed / 5 skipped. Connected world-lifecycle gate PASS (logs/connected-world-gate-20260729-150238); canonical nine-stop soak PASS (logs/connected-r6-soak-20260729-150856). Co-Authored-By: Claude Sonnet 5 --- .../retail-divergence-register.md | 3 +- .../2026-07-29-network-transport-campaign.md | 4 +- .../Transport/AckNakScheduler.cs | 181 ++- .../Transport/InboundSequenceTracker.cs | 197 +++- .../Transport/ReliableTransport.cs | 19 +- .../Transport/TransportStats.cs | 10 + src/AcDream.Core.Net/WorldSession.cs | 68 +- .../Transport/AckNakSchedulerTests.cs | 66 +- .../Transport/InboundSequenceTrackerTests.cs | 11 +- .../Transport/NakEmissionTests.cs | 1017 +++++++++++++++++ 10 files changed, 1467 insertions(+), 109 deletions(-) create mode 100644 tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ae536563..2a9ce69c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 42 rows (AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) +## 2. Adaptation (AD) — 43 rows (AD-51 filed 2026-07-29 at Campaign N slice N4 — the reclaimed-word pool for ACE's fresh-sequence cleartext RejectRetransmit; AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -70,6 +70,7 @@ accepted-divergence entries (#96, #49, #50). | AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is | | AD-48 | **Filed at Campaign V slice V11 (2026-07-29).** Presentation is paced by the Vulkan swapchain present mode (FIFO, i.e. VSync) or by a refresh-rate software pacer when uncapped, rather than by retail's D3D9 `Present` with its own frame-rate limiter. Frame delivery cadence, and therefore input-to-photon latency, is a property of our present path rather than a port of retail's. | `src/AcDream.App/RuntimeOptions.cs:98-100`; `src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs` | Retail's limiter and ours both bound the frame rate to the display; the simulation is fixed-step and clock-driven, so gameplay timing does not ride on presentation cadence. The uncapped path exists for measurement and is not the shipping default. | A pacing mismatch shows up as judder or input latency that differs from retail's feel without any visual difference in a captured frame — invisible to every pixel gate by construction. Issue **#235** (the capped/RDP jump-presentation cadence alias) is the known live instance of this class. | D3D9 `IDirect3DDevice9::Present`; retail's frame limiter in `RenderDeviceD3D` | | AD-50 | **Filed at Campaign N slice N2 (2026-07-29).** The inbound sequence tracker's watermark (`highestIDReceived_`) initializes to **1**, not retail's zero-init of `ReceiverData`. Watermark INIT only — every mechanism (sanity window, duplicate/parked-key path, gap walk, re-park, RejectRetransmit abandonment) is the verbatim retail port. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`AceInitialWatermark`) | ACE never emits S2C sequence 1: its `PacketSequence` starts unprimed at `uint.MaxValue`, the cleartext ConnectRequest takes NextValue 0, and the first ENCRYPTED flush re-primes CurrentValue to 1 so the first encrypted sequenced packet is 2 (ACE NetworkSession.cs:716-717 + Sequence/UIntSequence.cs:9-13,30-41; pinned by the N0 double and the N2 clean-lifecycle conformance test asserting min encrypted S2C sequence == 2 with zero NAKs). A zero-init watermark would gap-walk the permanent id-1 hole: one spurious NAK, the first pre-drawn word mis-assigned to id 1, and the keystream off by one from the very first encrypted packet. holtburger seeds the same value (crates/holtburger-session/src/session/api.rs:30, `last_server_seq: 1`), mirroring ACE's own C2S-side `lastReceivedPacketSequence = 1` (NetworkSession.cs:57). | Against a hypothetical server that DOES emit sequence 1 as its first encrypted packet (retail's own numbering), init-1 would classify it "not newer" and drop it as a duplicate — the mirror-image wedge. Only ACE-family servers exist for this client today. | `ReceiverData` zero-init (construction inside `SharedNet`; `highestIDReceived_` starts 0); `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the walk that would mis-NAK id 1) | +| AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | diff --git a/docs/plans/2026-07-29-network-transport-campaign.md b/docs/plans/2026-07-29-network-transport-campaign.md index 4f1ae254..b36a83c6 100644 --- a/docs/plans/2026-07-29-network-transport-campaign.md +++ b/docs/plans/2026-07-29-network-transport-campaign.md @@ -254,7 +254,7 @@ verbatim in each implementer prompt. | N0 | complete | `7e9134b4` + `e3958610` | ACE-behaviour double + virtual clock + lossy link; the review fix-up added the `Session.CheckState` inbound gate, faithful `SendBundle` coalescing/splitting, two-phase termination, an ACE-loose C2S fragment parse, and ACE's MessageBuffer edge cases. 687 Core.Net tests green. N1 folded in the re-review's `ProcessFragment` two-branch split (existing-buffer checks Complete; new-buffer parks without checking — the zero-count buffer stays parked). | | N1 | complete | `43e60a69` | Outbound sent-packet cache + resend on NAK (`Transport/`: `TransportClock`, `SequenceMath`, `SentPacketStore`, `OutboundFlowQueue`, `ReliableTransport`, `TransportStats`); `PacketCodec.FinalizeInPlace` sealed-checksum overload; `WorldSession` sweep in Tick + both handshake pump loops; TS-57 filed, TS-27 narrowed to inbound-only. **Fable review PASS.** Advisories: fresh sends keep `Time=0` (byte-identical wire; retail stamps the interval on every packet and ACE ignores the field — N3 folds the retail stamp in with the cadence work); the stale-NAK single-redundant-resend window is shared with retail (pending prunes at flush, after transmit — same frame order as `RecipientData::UseTime`). | | N2 | complete | `46d209d0` — **Fable review PASS** (tracker/codec-split/admission verified pre-commit against retail rules; gates lifecycle+nine-stop both PASS) | Inbound sequence-aligned ISAAC + NAK set (`Transport/InboundSequenceTracker`: watermark, sanity window, duplicate/parked-key path, sequence-ordered gap-walk pre-draw, verify-failure re-park, RejectRetransmit abandonment — `ProcessNewSeqNum @ 0x00544690`, `ProcessNewestSeqNum @ 0x00541930`, `SeqIDSanityCheck @ 0x00543A20`, `AddNakked @ 0x00549240`, `HandleEmptyAck @ 0x005448F0`); `PacketCodec` split into keystream-free `TryParseBorrowed` + `VerifyChecksum` (`TryDecodeBorrowed` deleted); `RejectRetransmit` ids exposed on both optional-header decoders; `ReliableTransport` now owns both keystreams; stats gained `InboundDupsDropped`/`InboundSanityDrops`/`ChecksumFailures`/`KeysParked`. Watermark init 1 is the AD-50 ACE adaptation (retail zero-init vs ACE's re-prime dance — first encrypted S2C is sequence 2; holtburger api.rs:30 agrees); pinned by the clean-lifecycle conformance test (zero NAKs, min encrypted S2C sequence == 2). 716 Core.Net tests green. **N3/N4 handoff note:** the interim per-packet reflex ack acks the ARRIVING sequence even while a gap is parked, so ACE prunes the lost id from its S2C cache (`AcknowledgeSequence` strictly-below) before N4 can NAK it — message-level recovery of a real loss needs N3's retail NAK-xor-ack sweep (§2.3's mutual exclusivity is load-bearing). Also noted for N4: ACE's `RejectRetransmit` consumes a fresh CLEARTEXT sequence via FlushPackets (no keystream word), so the client's gap walk parks a word for an id that never had one server-side — a real retail-vs-ACE incompatibility to resolve in N4's design (retail never assigns new sequences to cleartext). | -| N3 | complete | `0265cc42` — Opus review PASS (advisories folded into N4: transitional-state wording, SharedInit citation, pump-order wording, control-packet Time/Iteration rule, stale budget-break comment) | AckNakScheduler + 2.0 s cumulative ack (`Transport/AckNakScheduler`): ONE shared timestamp (`ReceiverData::timeStamp_` @ +0x10) arbitrating NAK-xor-ack per sweep (`ClientNet::ProcessConnection @ 0x00545450`); the ack is one cleartext exact-flags `AckSequence` carrying the tracker's `highestIDReceived_` behind the >= 2.0 s gate (`SharedNet::EnqueuePak @ 0x00543B10` — the binary's only 0x4000 construction site), armed at connection birth (`ReceiverData::Init @ 0x00548EF0`). The Phase 4.9 per-packet reflex ack and `WorldSession.SendAck` are DELETED; the `[net-tick]` acks/s probe now reads `Stats.AcksSent`. Sweep order per `FlowQueue::Empty @ 0x00548A20`: interval clock, NAK/ack arbitration, pending resends, prune. **N3 transitional state:** a non-empty NAK set suppresses the ack and emits NOTHING (no NAK yet) — safe for exactly one slice on loopback; N4 completes the branch. **N1 advisory retired (fold-in):** fresh reliable sends now stamp `Header.Time` = the current interval id (`FlowQueue::TransmitNewPackets @ 0x00547A60`, header build at 0x00547A84); ACE ignores inbound `Header.Time`, so the wire is unaffected. New `WorldSession.TransportClockSource` seam drives the gate on virtual time. 723 Core.Net tests green (keepalive property proven: a quiet session's 2 s acks refresh ACE's 60 s deadline across a 120 s virtual horizon; storm collapse: a 50-packet flood → ONE ack; the model accepts the reused-sequence ack without advancing its watermark). Connected lifecycle + canonical nine-stop gates PASS. | -| N4 | pending | — | | +| N3 | complete | `0265cc42` — Opus review PASS (advisories folded into N4: transitional-state wording, SharedInit citation, pump-order wording, control-packet Time/Iteration rule, stale budget-break comment) | AckNakScheduler + 2.0 s cumulative ack (`Transport/AckNakScheduler`): ONE shared timestamp (`ReceiverData::timeStamp_` @ +0x10) arbitrating NAK-xor-ack per sweep (`ClientNet::ProcessConnection @ 0x00545450`); the ack is one cleartext exact-flags `AckSequence` carrying the tracker's `highestIDReceived_` behind the >= 2.0 s gate (`SharedNet::EnqueuePak @ 0x00543B10` — the binary's only 0x4000 construction site), armed at connection birth (`ReceiverData::Init @ 0x00548EF0`). The Phase 4.9 per-packet reflex ack and `WorldSession.SendAck` are DELETED; the `[net-tick]` acks/s probe now reads `Stats.AcksSent`. Sweep order per `FlowQueue::Empty @ 0x00548A20`: interval clock, NAK/ack arbitration, pending resends, prune. **N3 transitional state (closed by N4):** a non-empty NAK set suppressed the ack and emitted NOTHING — the exposure was real even on loopback, just low-probability: one receive-buffer drop parks an id, every later sweep takes the silent NAK branch, acks stop (witness: `[net-tick] acks/s=0`), and ACE disconnects the quiet session at its 60 s timeout. N4 completed the branch. **N1 advisory retired (fold-in):** fresh reliable sends now stamp `Header.Time` = the current interval id (`FlowQueue::TransmitNewPackets @ 0x00547A60`, header build at 0x00547A84); ACE ignores inbound `Header.Time`, so the wire is unaffected. New `WorldSession.TransportClockSource` seam drives the gate on virtual time. 723 Core.Net tests green (keepalive property proven: a quiet session's 2 s acks refresh ACE's 60 s deadline across a 120 s virtual horizon; storm collapse: a 50-packet flood → ONE ack; the model accepts the reused-sequence ack without advancing its watermark). Connected lifecycle + canonical nine-stop gates PASS. | +| N4 | complete | SHA recorded at N5 kickoff | Client NAK emission + RejectRetransmit consumption. `AckNakScheduler` completes the NAK branch: one cleartext exact-flags `RequestRetransmit` per sweep behind the STRICT 0.6 s gate on the ONE shared timestamp (`SharedNet::EnqueueNaks @ 0x00543BD0` — the 0x41-mask x87 test at 0x00543C03 proceeds only on strictly-greater, contrast the ack's >=), body u32 count + ids ascending capped at 114 (`ReceiverData::GetNaks @ 0x005490C0`, cap 0x72), borrowed sequence, never an ack in a NAK sweep. Control-header rule decided for BOTH emissions: `Time` = interval id, `Iteration` = session iteration, per the shared retail header build (`FlowQueue::TransmitNewPackets @ 0x00547A60` @ 0x00547A84); ACE reads neither. THE design piece: the AD-51 reclaimed-word pool in `InboundSequenceTracker` closes the ACE cleartext-reject keystream hazard the N2 ledger row recorded — ACE's `RejectRetransmit` consumes a fresh cleartext sequence with NO keystream word, so the gap walk mis-parks a word for it and the whole inbound stream runs one word ahead. On a VALIDATED cleartext reject (`WorldSession` calls `OnCleartextRejectSequence` post-checksum), the tracker removes the mis-park, bubble-shifts every later-drawn parked word down one position (per-word draw ordinals; ascending id ⇔ ascending draw order), and pools the excess for the next fresh draws, consumed lowest-draw-order-first — exact for any number of interleaved rejects in any arrival order (a plain FIFO is NOT: reject-after-higher-arrival crosses the parked chain, and dual out-of-order rejects pool out of draw order — both pinned by tests). Reject BODY ids keep N2's discard (word-bearing server-side, consumed-in-place). N3 advisories all folded: honest transitional wording (above), `ReceiverData::SharedInit @ 0x00548EF0` (from `Init @ 0x00548FA0`) citation, `FlowQueue::Empty` pump-order comment (TransmitNaks → TransmitAcks → TransmitNewPackets, interval increment LAST @ 0x00548A9D; our clock-first order is cosmetic vs ACE), the Time/Iteration rule, and the stale `WorldSession` budget-break comment. Gate arithmetic hardened: gate ticks now round (0.6 has no exact double; truncation opened the strict gate AT the boundary). 737 Core.Net tests green, including: strict-gate boundary, shared-timestamp both directions, NAK-suppresses-ack, full wire-shape + 114-cap pins, model-served retransmission round trip, five tracker reclaim proofs, the 130-s virtual prune → fresh-sequence reject system test (victim abandoned, later traffic decodes, pool drains to zero), 10 s long-loss (NAKs on the gate cadence, zero acks, heal inside the window), and the capstone soak: 2% seeded bidirectional loss × 10,000 messages → zero message loss both ways, ACE crypto headroom 256 at convergence with a ≥250 no-erosion floor mid-flight, NAK set / reclaim pool / pending resends / ACE out-of-order buffer all zero, cache at the single watermark entry (retail Flush prunes STRICTLY below the ack). Soak notes: ACE never NAKs a quiet client (§3 row 1), so convergence keeps a C2S trickle flowing — a real idle-client tail loss heals only on the next action, an ACE constraint outside N4's scope. | | N5 | pending | — | | | N6 | pending | — | | diff --git a/src/AcDream.Core.Net/Transport/AckNakScheduler.cs b/src/AcDream.Core.Net/Transport/AckNakScheduler.cs index 34480588..28ffa069 100644 --- a/src/AcDream.Core.Net/Transport/AckNakScheduler.cs +++ b/src/AcDream.Core.Net/Transport/AckNakScheduler.cs @@ -4,7 +4,7 @@ using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Transport; /// -/// Campaign N Slice N3: retail's per-frame ack/NAK arbitration +/// Campaign N Slices N3+N4: retail's per-frame ack/NAK arbitration /// (ClientNet::ProcessConnection @ 0x00545450), replacing the /// Phase 4.9 per-packet reflex ack. Retail never acks per packet — /// SharedNet::EnqueuePak @ 0x00543B10 is the ONLY @@ -17,14 +17,13 @@ namespace AcDream.Core.Net.Transport; /// /// /// NAK set non-empty → the NAK branch -/// (SharedNet::EnqueueNaks @ 0x00543BD0, 0.6 s gate on the SAME -/// timestamp) and NO ack this sweep. N4 emits the -/// RequestRetransmit here; in N3 the branch exists but emits -/// NOTHING — parked NAKs mean neither ack nor NAK goes out. That -/// transitional state is safe for exactly one slice: on loopback gates -/// nothing creates stray parked ids, and with no NAK emission ACE never -/// produces the fresh-cleartext RejectRetransmit wrinkle the N2 ledger -/// row records. N4 completes the branch. +/// (SharedNet::EnqueueNaks @ 0x00543BD0): when +/// now − sharedTimestamp > 0.6 s — STRICTLY greater; the x87 +/// status test at 0x00543C03 masks 0x41 (C0 less | C3 equal) and jumps +/// away, so the branch proceeds only past the boundary, in contrast to the +/// ack's ≥ — emit ONE cleartext RequestRetransmit carrying up to +/// 114 parked ids ascending (ReceiverData::GetNaks @ 0x005490C0, +/// cap 0x72), stamp the shared timestamp, and send NO ack this sweep. /// Else, when now − sharedTimestamp ≥ 2.0 s: ONE cumulative /// AckSequence carrying the tracker's highestIDReceived_ /// (SharedNet::EnqueuePak @ 0x00543B10), then @@ -32,23 +31,47 @@ namespace AcDream.Core.Net.Transport; /// /// /// -/// The gate is armed at construction: retail's ReceiverData::Init -/// (@ 0x00548EF0, the timeStamp_ = Timer::cur_time store at -/// 0x00548F46) stamps the shared timestamp at connection birth, so the -/// first cumulative ack goes out 2.0 s after negotiation — the same shape -/// as ACE's own sendAck arming (NetworkSession.cs:54-55). Do NOT -/// shorten the gate: ACE's S2C cache holds 120 s and ACE's own ack cadence -/// is the same 2 s. +/// Historical note (N3 → N4): N3 shipped this class with the NAK branch +/// intentionally empty — parked ids suppressed the ack and emitted nothing. +/// The exposure was real even on loopback, just low-probability: one +/// receive-buffer drop parks an id, every subsequent sweep takes the silent +/// NAK branch, acks stop (witness: [net-tick] acks/s=0), and ACE +/// disconnects the quiet session at its 60 s timeout with no error packet. +/// N4 completed the branch; the emission below closes that window. /// /// /// -/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125): -/// flags EXACTLY — an equality, -/// never an OR (landmine #5: ACE's dedup exemption at NetworkSession.cs:342-343 -/// and the watermark-skip at :474-476 both require the exact value) — -/// cleartext (no ISAAC word), 4-byte little-endian body, header -/// Sequence borrowed from highestIDSent_ without incrementing, -/// Id = the session client id, Time/Iteration zero. +/// The gate is armed at construction: retail's +/// ReceiverData::SharedInit @ 0x00548EF0 (reached from +/// ReceiverData::Init @ 0x00548FA0; the timeStamp_ = +/// Timer::cur_time store at 0x00548F46) stamps the shared timestamp at +/// connection birth, so the first cumulative ack goes out 2.0 s after +/// negotiation — the same shape as ACE's own sendAck arming +/// (NetworkSession.cs:54-55). Do NOT shorten the gates: ACE's S2C cache +/// holds 120 s, ACE's own ack cadence is the same 2 s, and ACE rate-limits +/// its own NAKs at 1 s. +/// +/// +/// +/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125), the +/// control-packet header rule decided once for BOTH emissions: retail +/// transmits control headers through the same header build as fresh +/// reliable packets (FlowQueue::TransmitNewPackets @ 0x00547A60, +/// the stack build at 0x00547A84), which stamps Time = +/// CurLocalInterval_.intervalID_ and Iteration = the +/// receiver iteration — so the ack and the NAK here stamp +/// and the session iteration. ACE +/// reads neither field inbound (campaign §3), so the stamps are +/// retail-faithfulness, not ACE compatibility. Both packets are cleartext +/// (no ISAAC word) with the header Sequence borrowed from +/// highestIDSent_ without incrementing. Flags are EQUALITY-exact, +/// never an OR: the ack must be exactly +/// (landmine #5 — ACE's dedup +/// exemption at NetworkSession.cs:342-343 and the watermark-skip at +/// :474-476 both require the exact value) and the NAK exactly +/// (landmine #6 — ACE +/// honours only the cleartext form, NetworkSession.cs:283-284, and a NAK +/// never refreshes ACE's 60 s timeout). /// /// /// @@ -64,17 +87,31 @@ internal sealed class AckNakScheduler /// 2.0 at 0x00543B32). public const double AckGateSeconds = 2.0; + /// Retail's NAK gate + /// (SharedNet::EnqueueNaks @ 0x00543BD0, the x87 compare against + /// 0.6 at 0x00543BF8/0x00543C03 — strictly greater opens it). + public const double NakGateSeconds = 0.6; + + /// Retail's NAK-list cap + /// (ReceiverData::GetNaks @ 0x005490C0 clamps the returned count + /// at 0x72 = 114; ACE's own inbound cap is 115, NetworkSession.cs:381). + public const int MaxNakIdsPerPacket = 114; + + private readonly TransportClock _clock; private readonly InboundSequenceTracker _inbound; private readonly OutboundFlowQueue _outbound; private readonly TransportStats _stats; private readonly DatagramSendDelegate _send; private readonly ushort _sessionClientId; + private readonly ushort _sessionIteration; private readonly long _ackGateTicks; + private readonly long _nakGateTicks; + private readonly List _nakScratch = new(); /// THE shared timestamp (ReceiverData::timeStamp_ - /// @ +0x10). N4's 0.6 s NAK gate reads and writes this exact field — - /// never introduce a second timestamp (landmine #7): a NAK delays the - /// next ack and vice versa. + /// @ +0x10). Both gates read and write this exact field — never + /// introduce a second timestamp (landmine #7): a NAK delays the next + /// ack by 2.0 s and an ack delays the next NAK by 0.6 s. private long _sharedTimestamp; public AckNakScheduler( @@ -82,6 +119,7 @@ internal sealed class AckNakScheduler InboundSequenceTracker inbound, OutboundFlowQueue outbound, ushort sessionClientId, + ushort sessionIteration, TransportStats stats, DatagramSendDelegate send) { @@ -91,14 +129,21 @@ internal sealed class AckNakScheduler ArgumentNullException.ThrowIfNull(stats); ArgumentNullException.ThrowIfNull(send); + _clock = clock; _inbound = inbound; _outbound = outbound; _sessionClientId = sessionClientId; + _sessionIteration = sessionIteration; _stats = stats; _send = send; - _ackGateTicks = (long)(AckGateSeconds * clock.Frequency); - // ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time at - // connection birth: the gate starts armed, first ack at +2.0 s. + // Round, don't truncate: 0.6 has no exact double form and + // 0.6 × 10^7 truncates to 5,999,999 ticks — one tick short, which + // would open the STRICT gate at exactly 0.6 s elapsed. + _ackGateTicks = (long)Math.Round(AckGateSeconds * clock.Frequency); + _nakGateTicks = (long)Math.Round(NakGateSeconds * clock.Frequency); + // ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0) + // stamps timeStamp_ = cur_time at connection birth: the gates start + // armed, first ack at +2.0 s. _sharedTimestamp = clock.GetTimestamp(); } @@ -112,12 +157,15 @@ internal sealed class AckNakScheduler { if (_inbound.NakCount > 0) { - // The NAK branch (SharedNet::EnqueueNaks @ 0x00543BD0, 0.6 s - // gate on _sharedTimestamp). N4 emits the RequestRetransmit - // here; until then parked NAKs suppress the ack and nothing - // goes out this sweep — see the class doc for why that - // transitional state is safe for exactly this slice. The - // timestamp is NOT touched: only an actual emission stamps it. + // SharedNet::EnqueueNaks @ 0x00543BD0 — proceed only when the + // elapsed time is STRICTLY greater than 0.6 s (the 0x41 mask + // test at 0x00543C03 bails on less-than OR equal). At exactly + // 0.6 s the gate stays closed. Never an ack in a NAK sweep. + if (now - _sharedTimestamp <= _nakGateTicks) + return; + + EmitRequestRetransmit(); + _sharedTimestamp = now; return; } @@ -134,8 +182,7 @@ internal sealed class AckNakScheduler /// /// Build and send the one cumulative AckSequence /// (SharedNet::EnqueuePak @ 0x00543B10: mask 0x4000, 4-byte - /// payload = highestIDReceived_), byte-shaped exactly like the - /// pre-N3 reflex ack except the VALUE is the cumulative watermark. + /// payload = highestIDReceived_). /// private void EmitCumulativeAck() { @@ -154,6 +201,11 @@ internal sealed class AckNakScheduler Sequence = _outbound.HighestIdSent, Flags = PacketHeaderFlags.AckSequence, Id = _sessionClientId, + // The control-packet header rule (class doc): Time = interval + // id, Iteration = receiver iteration, per the shared retail + // header build at 0x00547A84. ACE reads neither inbound. + Time = _clock.IntervalId, + Iteration = _sessionIteration, }; int datagramLength = PacketCodec.FinalizeInPlace( @@ -165,4 +217,59 @@ internal sealed class AckNakScheduler _send(datagram.Slice(0, datagramLength)); _stats.AcksSent++; } + + /// + /// Build and send the one RequestRetransmit + /// (SharedNet::EnqueueNaks @ 0x00543BD0: mask 0x1000, body = + /// u32 count + count × u32 ids, data length 4·count + 4 — the + /// m_cbData store at 0x00543C3E — ids ascending from the AVL + /// walk, count capped at 114 by ReceiverData::GetNaks + /// @ 0x005490C0). Cleartext with flags EXACTLY + /// RequestRetransmit (landmine #6): ACE serves it at + /// NetworkSession.cs:283-308 and silently ignores any encrypted form. + /// The NAK set itself is untouched — parked entries (and their keys) + /// live until the retransmission decodes or the server abandons them. + /// + private void EmitRequestRetransmit() + { + _inbound.CopyNakkedSequencesAscending(_nakScratch, MaxNakIdsPerPacket); + int count = _nakScratch.Count; + int bodyLength = sizeof(uint) + count * sizeof(uint); + + Span datagram = stackalloc byte[ + PacketHeader.Size + sizeof(uint) + + MaxNakIdsPerPacket * sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian( + datagram.Slice(PacketHeader.Size), + (uint)count); + for (int i = 0; i < count; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian( + datagram.Slice( + PacketHeader.Size + sizeof(uint) + i * sizeof(uint)), + _nakScratch[i]); + } + + var header = new PacketHeader + { + // Borrowed sequence, exactly like the ack: the NAK is not part + // of the reliable stream. ACE's cleartext-NAK early handling + // (NetworkSession.cs:283-308) runs BEFORE its duplicate + // rejection, so the reused sequence is never dedup-dropped. + Sequence = _outbound.HighestIdSent, + Flags = PacketHeaderFlags.RequestRetransmit, + Id = _sessionClientId, + Time = _clock.IntervalId, + Iteration = _sessionIteration, + }; + + int datagramLength = PacketCodec.FinalizeInPlace( + header, + datagram, + bodyLength, + optionalLength: bodyLength, + outboundIsaac: null); + _send(datagram.Slice(0, datagramLength)); + _stats.NaksSent++; + } } diff --git a/src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs b/src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs index c5bea03b..d286c073 100644 --- a/src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs +++ b/src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs @@ -53,6 +53,43 @@ namespace AcDream.Core.Net.Transport; /// /// /// +/// The reclaimed-word pool (N4, ACE-only machinery — register AD-51). +/// Retail never assigns fresh sequences to cleartext packets (acks and NAKs +/// borrow; FlushPackets has no cleartext fresh-sequence path), so the +/// gap walk's assumption "every missing id was an encrypted packet whose +/// keystream word the server drew" holds on retail and the pool below is +/// provably empty against a retail server. ACE breaks the invariant in ONE +/// place: RejectRetransmit consumes a fresh sequence, cleartext, NO +/// keystream word, and is cached (ACE NetworkSession.cs:299-304 → +/// FlushPackets :710-735). Our gap walk therefore parks a word for an id the +/// server never drew one for, and the whole inbound stream runs one word +/// ahead — the exact desync class N2 closed, reintroduced through the reject +/// path. The fix, on a VALIDATED cleartext reject +/// (): +/// +/// remove the reject's own parked entry — its word was mis-drawn; +/// shift every parked entry drawn AFTER it down to its predecessor's +/// word (those parks counted the reject's id as word-consuming, so each sits +/// exactly one server position ahead); +/// push the excess word — the newest in the shifted chain — into the +/// reclaim pool, consumed lowest-draw-order-first by the next fresh +/// draws. +/// +/// Draw order is tracked per parked word () because +/// interleaved rejects can push excess words out of arrival order; consuming +/// the pool in draw order is what keeps our stream identical to the server's +/// ("its" next word is always our earliest unconsumed draw). The ids listed +/// INSIDE a reject body are the opposite case and keep N2's behavior: they +/// were real encrypted packets the server pruned, their words WERE drawn +/// server-side, so their parked words stay discarded (consumed-in-place). +/// One unreachable corner is accepted: a reject whose OWN id later appears +/// inside another reject's body (the first reject pruned after 120 s of +/// sustained loss while the session survives) would discard a word the +/// server never drew — probabilistically impossible against ACE's 60 s +/// timeout and the 0.6 s NAK cadence. +/// +/// +/// /// Single-threaded by design (the ISAAC keystream is order-sensitive), like /// the rest of the transport: every member runs on the session's frame /// thread. @@ -86,19 +123,48 @@ internal sealed class InboundSequenceTracker private readonly IsaacRandom _inboundIsaac; private readonly TransportStats _stats; + /// One pre-drawn keystream word plus its position in our draw + /// sequence. The draw ordinal is what lets the AD-51 reject reclaim keep + /// the pool aligned to the server's stream when rejects interleave — + /// within the NAK set, ascending wrap-safe id ⇔ ascending draw order + /// (walks park in sequence order, later walks park later ids, and the + /// pool is consumed lowest-first before any fresh draw). + internal readonly record struct ParkedWord(uint Word, ulong DrawOrder); + /// Retail m_SeqIDsWeNAKed: missing sequence → the /// pre-drawn keystream word parked for it. - private readonly SortedDictionary _nakSet = new(); + private readonly SortedDictionary _nakSet = new(); + + /// The AD-51 reclaim pool: words we drew for ids ACE never drew + /// one for (cleartext RejectRetransmit fresh sequences), consumed + /// lowest-draw-order-first by before any fresh + /// ISAAC draw. Provably empty against a retail server. + private readonly PriorityQueue _reclaimedWords = new(); + + /// Scratch for the reject bubble-shift — reused, never + /// allocated on the steady-state path. + private readonly List _rejectShiftScratch = new(); + + /// Monotonic ordinal stamped on every fresh inbound ISAAC + /// draw; positions in this sequence ARE server stream positions minus + /// the reclaimed (never-drawn-server-side) entries. + private ulong _drawOrdinal; /// Retail highestIDReceived_ — the newest sequence ever /// admitted (NOT "highest fully processed"; the gap walk advances it /// past holes). public uint HighestIdReceived { get; private set; } - /// Missing ids currently carrying a parked key — the value - /// N4's RequestRetransmit emission drains. + /// Missing ids currently carrying a parked key — the set the + /// N4 RequestRetransmit emission copies (never drains: entries + /// leave only on parked-key decode or RejectRetransmit abandonment). public int NakCount => _nakSet.Count; + /// Words currently sitting in the AD-51 reclaim pool. Zero + /// against retail servers; against ACE it drains as the next fresh + /// draws consume it — a converged session ends at zero. + public int ReclaimedWordCount => _reclaimedWords.Count; + public InboundSequenceTracker( IsaacRandom inboundIsaac, TransportStats stats, @@ -115,14 +181,23 @@ internal sealed class InboundSequenceTracker /// The verdict for one arriving sequenced packet. — /// discard without touching the checksum. Otherwise verify with /// : the keystream word for encrypted packets, - /// null for the additive cleartext form. + /// null for the additive cleartext form. + /// travels with the key so a checksum-failure re-park + /// () keeps the word's draw position — the AD-51 + /// reject reclaim needs it to know which parked entries a mis-park + /// shifted. /// - public readonly record struct Admission(bool Drop, uint? VerifyKey) + public readonly record struct Admission( + bool Drop, + uint? VerifyKey, + ulong VerifyKeyDrawOrder) { - public static Admission Dropped => new(true, null); + public static Admission Dropped => new(true, null, 0); - public static Admission Process(uint? verifyKey) => - new(false, verifyKey); + public static Admission Process( + uint? verifyKey, + ulong verifyKeyDrawOrder = 0) => + new(false, verifyKey, verifyKeyDrawOrder); } /// @@ -151,10 +226,10 @@ internal sealed class InboundSequenceTracker // miss is a duplicate of an already-decoded packet and drops at // zero keystream cost. Cleartext packets skip this entirely // (retail reprocesses cleartext dups; they never touch the wheel). - uint? parkedKey = null; + ParkedWord? parkedKey = null; if (encrypted && !newer) { - if (!_nakSet.Remove(sequence, out uint parked)) + if (!_nakSet.Remove(sequence, out ParkedWord parked)) { _stats.InboundDupsDropped++; return Admission.Dropped; @@ -186,8 +261,10 @@ internal sealed class InboundSequenceTracker return Admission.Process(null); // Step 4 — the packet's own key: parked when step 2 found one, - // else the next fresh word. - return Admission.Process(parkedKey ?? _inboundIsaac.Next()); + // else the next word (the AD-51 reclaim pool ahead of a fresh + // ISAAC draw — identical against retail, where the pool is empty). + ParkedWord own = parkedKey ?? NextWord(); + return Admission.Process(own.Word, own.DrawOrder); } /// @@ -196,13 +273,16 @@ internal sealed class InboundSequenceTracker /// (ProcessPacket @ 0x00544790 tail, /// AddNakked(seq, &key)) so the byte-identical retransmission /// decodes with the same word. Idempotent like retail's AddNakked. + /// is the admission's + /// — the word keeps its draw + /// position across the re-park. /// - public void ReparkKey(uint sequence, uint key) + public void ReparkKey(uint sequence, uint key, ulong drawOrder) { if (_nakSet.ContainsKey(sequence)) return; - _nakSet.Add(sequence, key); + _nakSet.Add(sequence, new ParkedWord(key, drawOrder)); _stats.KeysParked++; } @@ -228,17 +308,94 @@ internal sealed class InboundSequenceTracker } /// - /// Copy the NAKed ids in ascending raw-uint order — the same in-order - /// enumeration retail's AVL yields (ReceiverData::GetNaks - /// @ 0x005490C0 walks it ascending for the ≤114-id NAK list). N4 - /// adds the cap; this is the simple full copy. + /// Copy up to NAKed ids in ascending + /// raw-uint order — the same in-order enumeration retail's AVL yields, + /// with retail's cap applied at the same layer + /// (ReceiverData::GetNaks @ 0x005490C0 walks the tree ascending + /// and clamps the returned count at 0x72 = 114). The set itself is + /// untouched: entries leave only on parked-key decode, abandonment, or + /// the AD-51 reclaim. /// - public void CopyNakkedSequencesAscending(List destination) + public void CopyNakkedSequencesAscending( + List destination, + int maxCount = int.MaxValue) { ArgumentNullException.ThrowIfNull(destination); destination.Clear(); foreach (uint sequence in _nakSet.Keys) + { + if (destination.Count >= maxCount) + break; destination.Add(sequence); + } + } + + /// + /// AD-51 (N4): a VALIDATED cleartext RejectRetransmit arrived at + /// — an id ACE consumed fresh WITHOUT + /// drawing a keystream word (ACE FlushPackets, NetworkSession.cs:722-725 + /// takes NextValue for the reject; SendPacket :743-748 draws no word for + /// a cleartext packet). Our gap walk parked a word for it under the + /// retail invariant "missing ⇒ encrypted ⇒ word-bearing"; undo the + /// mis-park exactly: + /// + /// remove the reject's parked entry; + /// shift every parked entry drawn after it down to its + /// predecessor's word — each was assigned one draw position past where + /// the server's stream really sits; + /// push the chain's newest word into the reclaim pool for the + /// next fresh draw (lowest draw order first). + /// + /// Idempotent: a retransmitted reject (flags + /// RejectRetransmit|Retransmission, still cleartext) finds no + /// entry and no-ops. The caller triggers this only after checksum + /// verification — an unvalidated datagram must never move keystream + /// state. Retail comparison point: no such path exists in the retail + /// client because retail servers never assign fresh sequences to + /// cleartext packets (FlowQueue::TransmitNewPackets @ 0x00547A60 + /// sequences only reliable packets; control emissions borrow). + /// + public void OnCleartextRejectSequence(uint sequence) + { + if (!_nakSet.Remove(sequence, out ParkedWord reclaimed)) + return; + + // Everything drawn after the mis-park sits one server position + // ahead. Ascending draw order ⇔ ascending wrap-safe id inside the + // set, so ordering by draw ordinal is both wrap-proof and exactly + // "the ids newer than the reject". + _rejectShiftScratch.Clear(); + foreach (KeyValuePair entry in _nakSet) + { + if (entry.Value.DrawOrder > reclaimed.DrawOrder) + _rejectShiftScratch.Add(entry.Key); + } + + _rejectShiftScratch.Sort( + (a, b) => _nakSet[a].DrawOrder.CompareTo(_nakSet[b].DrawOrder)); + + ParkedWord carry = reclaimed; + foreach (uint id in _rejectShiftScratch) + { + ParkedWord displaced = _nakSet[id]; + _nakSet[id] = carry; + carry = displaced; + } + + _reclaimedWords.Enqueue(carry.Word, carry.DrawOrder); + _stats.RejectWordsReclaimed++; + } + + /// The next inbound word in server-stream order: the reclaim + /// pool's earliest draw when one is waiting (AD-51 — the server is + /// still on that position), else a fresh ISAAC draw stamped with the + /// next ordinal. + private ParkedWord NextWord() + { + if (_reclaimedWords.TryDequeue(out uint word, out ulong order)) + return new ParkedWord(word, order); + + return new ParkedWord(_inboundIsaac.Next(), ++_drawOrdinal); } /// @@ -251,7 +408,7 @@ internal sealed class InboundSequenceTracker if (_nakSet.ContainsKey(sequence)) return; - _nakSet.Add(sequence, _inboundIsaac.Next()); + _nakSet.Add(sequence, NextWord()); _stats.KeysParked++; } } diff --git a/src/AcDream.Core.Net/Transport/ReliableTransport.cs b/src/AcDream.Core.Net/Transport/ReliableTransport.cs index decfffaa..7eb27ee7 100644 --- a/src/AcDream.Core.Net/Transport/ReliableTransport.cs +++ b/src/AcDream.Core.Net/Transport/ReliableTransport.cs @@ -44,6 +44,7 @@ internal sealed class ReliableTransport : IDisposable IsaacRandom outboundIsaac, IsaacRandom inboundIsaac, ushort sessionClientId, + ushort sessionIteration, DatagramSendDelegate send, TransportClock? clock = null, ArrayPool? pool = null) @@ -63,6 +64,7 @@ internal sealed class ReliableTransport : IDisposable Inbound, Outbound, sessionClientId, + sessionIteration, Stats, send); Stats.CacheDepthSource = () => Outbound.CacheDepth; @@ -74,13 +76,16 @@ internal sealed class ReliableTransport : IDisposable /// /// One transport pump: interval clock forward, NAK-xor-ack arbitration, - /// pending NAKed resends out, acked cache entries pruned. Pump order per - /// retail FlowQueue::Empty @ 0x00548A20: the interval clock, then - /// the control-packet arbitration (ClientNet::ProcessConnection - /// @ 0x00545450 enqueues NAKs-or-ack before the flow queue drains), - /// then retransmits, then new packets — new packets are sent - /// synchronously by the session, so the sweep runs before the frame's - /// sends the same way retail's per-frame pump does. + /// pending NAKed resends out, acked cache entries pruned. Retail's + /// FlowQueue::Empty @ 0x00548A20 drains + /// TransmitNaks → TransmitAcks → TransmitNewPackets and advances + /// the interval clock LAST (the 0.5 s walk + + /// IncrementLocalInterval at 0x00548A9D); our Sweep advances the + /// clock FIRST. The divergence is cosmetic against ACE — it only shifts + /// which interval id lands in Header.Time at an interval + /// boundary, and ACE never reads that field inbound (campaign §3). New + /// packets are sent synchronously by the session, so the sweep runs + /// before the frame's sends the same way retail's per-frame pump does. /// public void Sweep() { diff --git a/src/AcDream.Core.Net/Transport/TransportStats.cs b/src/AcDream.Core.Net/Transport/TransportStats.cs index 3c06ea21..96382af3 100644 --- a/src/AcDream.Core.Net/Transport/TransportStats.cs +++ b/src/AcDream.Core.Net/Transport/TransportStats.cs @@ -52,6 +52,16 @@ internal sealed class TransportStats /// only ack construction site; there is no per-packet ack). public long AcksSent; + /// N4: RequestRetransmit packets emitted by the 0.6 s + /// NAK branch (SharedNet::EnqueueNaks @ 0x00543BD0 → + /// ReceiverData::GetNaks @ 0x005490C0, ≤114 ids each). + public long NaksSent; + + /// N4: mis-parked keystream words reclaimed from validated + /// cleartext RejectRetransmit sequences (the AD-51 ACE + /// adaptation; always zero against a retail server). + public long RejectWordsReclaimed; + /// Live sent-packet cache depth — the N5 watchdog value /// (cache=N in [net-tick]; the cache is unbounded like /// retail's, so depth is the health signal, not a cap). diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index bac7ae52..4658374e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -900,20 +900,22 @@ public sealed class WorldSession : IDisposable // generation into connection-level control packets, including the // final disconnect. ACE currently emits iteration 1. _sessionIteration = connectRequestIteration; - // N1+N2+N3: the reliable transport is born at ISAAC-seeding time, - // owning BOTH keystreams and the ack/NAK sweep. Outbound: + // N1+N2+N3+N4: the reliable transport is born at ISAAC-seeding + // time, owning BOTH keystreams and the ack/NAK sweep. Outbound: // highestIDSent starts 1 (the ConnectResponse below carries // sequence 1), so the first reliable packet after the handshake // keeps packet sequence 2 and fragment sequence 1. Inbound: the // tracker owns the server keystream, the received watermark, and // the NAK set (campaign §2.2); its watermark starts 1 (see // InboundSequenceTracker.AceInitialWatermark). The scheduler's - // 2.0 s cumulative-ack gate arms here, at connection birth - // (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time). + // shared ack/NAK gate arms here, at connection birth + // (ReceiverData::SharedInit @ 0x00548EF0, reached from + // ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time). _transport = new ReliableTransport( new IsaacRandom(clientSeedBytes), new IsaacRandom(serverSeedBytes), _sessionClientId, + _sessionIteration, datagram => _net.Send(datagram), clock: TransportClockSource is { } clockSource ? new TransportClock( @@ -1070,10 +1072,12 @@ public sealed class WorldSession : IDisposable processed++; // Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick // (the async receive owner starts at Transition(State.InWorld)). - // Acks are queued per packet inside ProcessDatagram BEFORE the heavy handler, so - // deferring the tail only delays the tail's acks a few frames — within ACE's - // tolerance (holtburger defers acks on a flush cadence). The tail stays queued - // (unbounded channel, FIFO) and drains next frame. + // Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on + // the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a + // deferred inbound tail never defers a due ack, NAK, or resend. The tail itself + // stays queued (unbounded channel, FIFO) and drains next frame — its only cost + // is that the cumulative ack keeps carrying the pre-tail watermark until the + // tail is processed, well inside ACE's 120 s cache retention. if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks)) { budgetBroke = true; @@ -1090,12 +1094,13 @@ public sealed class WorldSession : IDisposable } /// - /// N1: one reliable-transport pump slice (retail + /// N1+N3+N4: one reliable-transport pump slice (retail /// PacketController::UseTime @ 0x005410D0 shape): interval clock - /// forward, pending NAKed resends out, acked cache pruned. Gated on - /// negotiation — ACE's Session.CheckState silently discards - /// pre-negotiation control traffic (campaign landmine #8), and the - /// transport does not exist before the ISAAC seeds do. + /// forward, NAK-xor-ack arbitration, pending NAKed resends out, acked + /// cache pruned. Gated on negotiation — ACE's + /// Session.CheckState silently discards pre-negotiation control + /// traffic (campaign landmine #8), and the transport does not exist + /// before the ISAAC seeds do. /// private void SweepTransport() { @@ -1385,7 +1390,8 @@ public sealed class WorldSession : IDisposable { inboundTransport.Inbound.ReparkKey( serverHeader.Sequence, - admission.VerifyKey!.Value); + admission.VerifyKey!.Value, + admission.VerifyKeyDrawOrder); } return; @@ -1433,15 +1439,33 @@ public sealed class WorldSession : IDisposable } // N2: inbound RejectRetransmit (0x2000) — the server abandoned - // these ids; drop them from the NAK set, discarding the parked - // keys (SharedNet::HandleEmptyAck @ 0x005448F0). Alignment - // holds: the words were already drawn in sequence order. - if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0 - && packet.Optional.RejectRetransmitCount > 0) + // the ids in the BODY; drop them from the NAK set, discarding + // the parked keys (SharedNet::HandleEmptyAck @ 0x005448F0). + // Alignment holds for those ids: they were real encrypted + // packets, so their words were drawn on both sides and are + // consumed-in-place. + if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0) { - transport.Inbound.OnRejectRetransmit( - packet.Optional.RejectRetransmitBytes.Span, - packet.Optional.RejectRetransmitCount); + if (packet.Optional.RejectRetransmitCount > 0) + { + transport.Inbound.OnRejectRetransmit( + packet.Optional.RejectRetransmitBytes.Span, + packet.Optional.RejectRetransmitCount); + } + + // N4/AD-51 — the reject packet's OWN sequence is the + // opposite case: ACE consumed it fresh, cleartext, with NO + // keystream word (FlushPackets, NetworkSession.cs:722-725, + // :743-748), so the word our gap walk parked for it was + // never drawn server-side. Reclaim it (checksum already + // verified above — the trigger fires only on a VALIDATED + // packet). Retail never reaches this: its cleartext packets + // always borrow live sequences. + if (serverHeader.Sequence != 0 && !encrypted) + { + transport.Inbound.OnCleartextRejectSequence( + serverHeader.Sequence); + } } // Cumulative ack (AckSequence 0x4000): wrap-safe max into the diff --git a/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs b/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs index f512a2dd..51964bc4 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs @@ -20,6 +20,7 @@ public sealed class AckNakSchedulerTests private const uint ClientSeed = 0x11AA22BBu; private const uint ServerSeed = 0x33CC44DDu; private const uint ClientId = 0x1234u; + private const ushort SessionIteration = 0x0007; private const ulong Cookie = 0xFEEDFACECAFEBABEUL; // ===================================================================== @@ -35,17 +36,25 @@ public sealed class AckNakSchedulerTests Admit(transport, 3u); // No ack before 2.0 s — the gate armed at transport construction - // (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time). + // (ReceiverData::SharedInit @ 0x00548EF0, reached from + // ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time). transport.Sweep(); clock.Advance(TimeSpan.FromSeconds(1.99)); transport.Sweep(); Assert.Empty(sent); // Exactly one at the boundary (the retail compare is >=, the x87 - // `& 1` status test at 0x00543B3D). + // `& 1` status test at 0x00543B3D). Header.Time carries the + // interval id at emission (N4 control-header rule): 2.0 s of + // 0.5 s intervals on top of the initial id 1 → 5. clock.Advance(TimeSpan.FromSeconds(0.01)); transport.Sweep(); - AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 3u); + Assert.Equal((ushort)5, transport.Clock.IntervalId); + AssertAckShape( + Assert.Single(sent), + expectedSequence: 1u, + expectedValue: 3u, + expectedTime: transport.Clock.IntervalId); Assert.Equal(1, transport.Stats.AcksSent); // The gate reset: silent until the next 2.0 s elapses. @@ -61,7 +70,11 @@ public sealed class AckNakSchedulerTests clock.Advance(TimeSpan.FromSeconds(0.01)); transport.Sweep(); Assert.Equal(2, sent.Count); - AssertAckShape(sent[1], expectedSequence: 1u, expectedValue: 5u); + AssertAckShape( + sent[1], + expectedSequence: 1u, + expectedValue: 5u, + expectedTime: transport.Clock.IntervalId); Assert.Equal(2, transport.Stats.AcksSent); } @@ -126,23 +139,37 @@ public sealed class AckNakSchedulerTests Admit(transport, 4u); // gap walk parks id 3 Assert.Equal(1, transport.Inbound.NakCount); - // 2.0 s elapses with the NAK set non-empty: the NAK branch owns the - // sweep and (until N4 emits RequestRetransmit there) NOTHING goes - // out — never an ack while ids are parked (§2.3's mutual - // exclusivity; the N2 ledger row shows why acking here would let - // ACE prune the lost id from its S2C cache before the NAK). + // 2.5 s elapses with the NAK set non-empty: the NAK branch owns the + // sweep — ONE RequestRetransmit goes out and never an ack while ids + // are parked (§2.3's mutual exclusivity; the N2 ledger row shows why + // acking here would let ACE prune the lost id from its S2C cache + // before the NAK). The immediate second sweep is silenced by the + // freshly stamped shared timestamp. clock.Advance(TimeSpan.FromSeconds(2.5)); transport.Sweep(); transport.Sweep(); - Assert.Empty(sent); + byte[] nak = Assert.Single(sent); + Assert.Equal( + (uint)PacketHeaderFlags.RequestRetransmit, + (uint)PacketHeader.Unpack(nak).Flags); Assert.Equal(0, transport.Stats.AcksSent); + Assert.Equal(1, transport.Stats.NaksSent); + sent.Clear(); // The missing packet arrives (late delivery), clearing the set — - // the ack resumes at the next gate, which is long since due. + // the ack resumes once 2.0 s elapse past the NAK's stamp of the + // SHARED timestamp (landmine #7: a NAK delays the next ack). Admit(transport, 3u); Assert.Equal(0, transport.Inbound.NakCount); transport.Sweep(); - AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 4u); + Assert.Empty(sent); + clock.Advance(TimeSpan.FromSeconds(2.0)); + transport.Sweep(); + AssertAckShape( + Assert.Single(sent), + expectedSequence: 1u, + expectedValue: 4u, + expectedTime: transport.Clock.IntervalId); } // ===================================================================== @@ -175,7 +202,8 @@ public sealed class AckNakSchedulerTests AssertAckShape( Assert.Single(sent), expectedSequence: 1u, - expectedValue: 51u); + expectedValue: 51u, + expectedTime: transport.Clock.IntervalId); Assert.Equal(1, transport.Stats.AcksSent); } @@ -343,6 +371,7 @@ public sealed class AckNakSchedulerTests MakeIsaac(ClientSeed), MakeIsaac(ServerSeed), (ushort)ClientId, + SessionIteration, datagram => sent.Add(datagram.ToArray()), new TransportClock( virtualClock.GetTimestamp, @@ -364,12 +393,15 @@ public sealed class AckNakSchedulerTests /// uint compare fails if ANY extra bit is ORed in (landmine #5) — /// cleartext (decodes with a null keystream), 4-byte little-endian /// body carrying the watermark, borrowed header sequence, session - /// client id, Time/Iteration zero. + /// client id, and the N4 control-header rule: Time = the + /// interval id at emission and Iteration = the session + /// iteration (retail's shared header build at 0x00547A84). /// private static void AssertAckShape( byte[] datagram, uint expectedSequence, - uint expectedValue) + uint expectedValue, + ushort expectedTime) { Assert.Equal(PacketHeader.Size + sizeof(uint), datagram.Length); PacketHeader header = PacketHeader.Unpack(datagram); @@ -378,8 +410,8 @@ public sealed class AckNakSchedulerTests (uint)header.Flags); Assert.Equal(expectedSequence, header.Sequence); Assert.Equal((ushort)ClientId, header.Id); - Assert.Equal((ushort)0, header.Time); - Assert.Equal((ushort)0, header.Iteration); + Assert.Equal(expectedTime, header.Time); + Assert.Equal(SessionIteration, header.Iteration); Assert.Equal((ushort)sizeof(uint), header.DataSize); Assert.Equal( expectedValue, diff --git a/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs b/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs index 28c50de7..0924953f 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs @@ -101,9 +101,14 @@ public sealed class InboundSequenceTrackerTests uint w11 = shadow.Next(); // 10 arrives corrupt: admission consumed w10, verification failed, - // the session re-parks the consumed key (step 5) and drops. - Assert.Equal(w10, Admitted(tracker, 10)); - tracker.ReparkKey(10, w10); + // the session re-parks the consumed key (step 5) — carrying the + // admission's draw order, so the AD-51 reclaim can still place the + // word in the stream — and drops. + InboundSequenceTracker.Admission corrupt = + tracker.Admit(10, encrypted: true); + Assert.False(corrupt.Drop); + Assert.Equal(w10, corrupt.VerifyKey); + tracker.ReparkKey(10, w10, corrupt.VerifyKeyDrawOrder); Assert.Equal(1, tracker.NakCount); Assert.Equal(1, stats.KeysParked); diff --git a/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs b/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs new file mode 100644 index 00000000..62ea2300 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs @@ -0,0 +1,1017 @@ +using System.Buffers.Binary; +using System.Net; +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; + +namespace AcDream.Core.Net.Tests.Transport; + +/// +/// Campaign N Slice N4 — client NAK emission + RejectRetransmit +/// consumption: the 0.6 s STRICT gate on the shared timestamp +/// (SharedNet::EnqueueNaks @ 0x00543BD0, the 0x41-mask test at +/// 0x00543C03), the ≤114-id ascending cleartext RequestRetransmit +/// (ReceiverData::GetNaks @ 0x005490C0, cap 0x72), and the AD-51 +/// reclaimed-word pool that closes the ACE cleartext-reject keystream +/// hazard (ACE's RejectRetransmit consumes a fresh cleartext +/// sequence WITHOUT drawing a keystream word — NetworkSession.cs:299-304, +/// :722-725, :743-748 — which retail's gap-walk invariant never +/// anticipates). +/// +public sealed class NakEmissionTests +{ + private const uint ClientSeed = 0x77EE88FFu; + private const uint ServerSeed = 0x55DD66CCu; + private const uint ClientId = 0x1234u; + private const ushort SessionIteration = 0x0007; + private const uint TrackerSeed = 0x5EED5EEDu; + + // ===================================================================== + // The 0.6 s gate is STRICT — SharedNet::EnqueueNaks @ 0x00543BD0 + // ===================================================================== + + [Fact] + public void NakGate_ClosedAtExactly600ms_OpensJustPastIt() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + Admit(transport, 2u); + Admit(transport, 4u); // parks id 3 + Assert.Equal(1, transport.Inbound.NakCount); + + // Exactly 0.6 s since the gate armed at construction: STILL CLOSED + // (the decomp's 0x41 mask bails on less-than OR equal — the branch + // proceeds only on strictly greater; contrast the ack's >=). + clock.Advance(TimeSpan.FromSeconds(0.6)); + transport.Sweep(); + Assert.Empty(sent); + Assert.Equal(0, transport.Stats.NaksSent); + + // One millisecond past: open. + clock.Advance(TimeSpan.FromMilliseconds(1)); + transport.Sweep(); + Assert.Single(sent); + Assert.Equal(1, transport.Stats.NaksSent); + + // The stamp reset the gate: exactly 0.6 s later is closed again, + // just past opens again. + transport.Sweep(); + clock.Advance(TimeSpan.FromSeconds(0.6)); + transport.Sweep(); + Assert.Single(sent); + clock.Advance(TimeSpan.FromMilliseconds(1)); + transport.Sweep(); + Assert.Equal(2, sent.Count); + Assert.Equal(2, transport.Stats.NaksSent); + } + + // ===================================================================== + // ONE shared timestamp (landmine #7) — ReceiverData::timeStamp_ @ +0x10 + // ===================================================================== + + [Fact] + public void SharedTimestamp_AckDelaysNak_NakDelaysAck() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + Admit(transport, 2u); + + // An ack goes out at t = 2.0 and stamps the SHARED timestamp. + clock.Advance(TimeSpan.FromSeconds(2.0)); + transport.Sweep(); + Assert.Equal(1, transport.Stats.AcksSent); + sent.Clear(); + + // A gap parks. The ack's stamp gates the NAK: exactly 0.6 s after + // the ACK is still closed; just past opens. + Admit(transport, 4u); // parks id 3 + clock.Advance(TimeSpan.FromSeconds(0.6)); + transport.Sweep(); + Assert.Empty(sent); + clock.Advance(TimeSpan.FromMilliseconds(1)); + transport.Sweep(); + byte[] nak = Assert.Single(sent); + Assert.Equal( + (uint)PacketHeaderFlags.RequestRetransmit, + (uint)PacketHeader.Unpack(nak).Flags); + sent.Clear(); + + // The NAK's stamp gates the ack the same way: the gap heals, and + // the next cumulative ack waits the full 2.0 s from the NAK. + Admit(transport, 3u); + Assert.Equal(0, transport.Inbound.NakCount); + clock.Advance(TimeSpan.FromSeconds(1.999)); + transport.Sweep(); + Assert.Empty(sent); + Assert.Equal(1, transport.Stats.AcksSent); + clock.Advance(TimeSpan.FromSeconds(0.001)); + transport.Sweep(); + Assert.Single(sent); + Assert.Equal(2, transport.Stats.AcksSent); + } + + [Fact] + public void NakSweep_BothGatesOpen_EmitsTheNakAndNeverTheAck() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + Admit(transport, 2u); + Admit(transport, 4u); // parks id 3 + + // Both gates are long since open; the NAK branch owns the sweep + // exclusively (ClientNet::ProcessConnection @ 0x00545450 — + // EnqueueNaks XOR EnqueuePak, never both). + clock.Advance(TimeSpan.FromSeconds(5.0)); + transport.Sweep(); + byte[] nak = Assert.Single(sent); + Assert.Equal( + (uint)PacketHeaderFlags.RequestRetransmit, + (uint)PacketHeader.Unpack(nak).Flags); + Assert.Equal(1, transport.Stats.NaksSent); + Assert.Equal(0, transport.Stats.AcksSent); + } + + // ===================================================================== + // Emission shape — SharedNet::EnqueueNaks @ 0x00543BD0 (mask 0x1000, + // m_cbData = 4·count + 4 at 0x00543C3E) + landmine #6 + // ===================================================================== + + [Fact] + public void NakShape_CleartextExactFlags_BorrowedSequence_AscendingIds() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + + // Two reliable sends so the borrowed sequence is nontrivial. + transport.Outbound.SendGameMessage( + MakeMessage(1), GameMessageGroup.UIQueue); + transport.Outbound.SendGameMessage( + MakeMessage(2), GameMessageGroup.UIQueue); + Assert.Equal(3u, transport.Outbound.HighestIdSent); + sent.Clear(); + + Admit(transport, 2u); + Admit(transport, 6u); // parks 3, 4, 5 + clock.Advance(TimeSpan.FromSeconds(0.7)); + transport.Sweep(); + + byte[] nak = Assert.Single(sent); + PacketHeader header = PacketHeader.Unpack(nak); + + // Flags: a raw equality — EXACTLY RequestRetransmit, no + // EncryptedChecksum (landmine #6: ACE honours only the cleartext + // form, NetworkSession.cs:283-284). + Assert.Equal( + (uint)PacketHeaderFlags.RequestRetransmit, + (uint)header.Flags); + + // Borrowed sequence (no increment), session id, and the N4 + // control-header rule: Time = interval id at emission, Iteration = + // the session iteration (retail's shared header build at + // 0x00547A84). + Assert.Equal(transport.Outbound.HighestIdSent, header.Sequence); + Assert.Equal(3u, transport.Outbound.HighestIdSent); + Assert.Equal((ushort)ClientId, header.Id); + Assert.Equal(transport.Clock.IntervalId, header.Time); + Assert.Equal((ushort)2, header.Time); // 0.7 s of 0.5 s intervals + 1 + Assert.Equal(SessionIteration, header.Iteration); + + // Body: u32 count + count × u32 ascending, little-endian. + Assert.Equal((ushort)16, header.DataSize); + Assert.Equal(PacketHeader.Size + 16, nak.Length); + Assert.Equal( + 3u, + BinaryPrimitives.ReadUInt32LittleEndian( + nak.AsSpan(PacketHeader.Size))); + Assert.Equal( + 3u, + BinaryPrimitives.ReadUInt32LittleEndian( + nak.AsSpan(PacketHeader.Size + 4))); + Assert.Equal( + 4u, + BinaryPrimitives.ReadUInt32LittleEndian( + nak.AsSpan(PacketHeader.Size + 8))); + Assert.Equal( + 5u, + BinaryPrimitives.ReadUInt32LittleEndian( + nak.AsSpan(PacketHeader.Size + 12))); + + // Cleartext: decodes with a null keystream, and our own parser + // reads the id list back. + PacketCodec.PacketDecodeResult decoded = + PacketCodec.TryDecode(nak, inboundIsaac: null); + Assert.True(decoded.IsOk, decoded.Error.ToString()); + Assert.Equal( + new uint[] { 3u, 4u, 5u }, + decoded.Packet!.Optional.RetransmitRequests); + + // The emission COPIES the set — parked entries (and their keys) + // stay until the retransmission decodes or the server abandons + // them. + Assert.Equal(3, transport.Inbound.NakCount); + } + + [Fact] + public void NakList_CapsAt114LowestAscending_SetUntouched() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + Admit(transport, 2u); + Admit(transport, 203u); // parks 3..202 — 200 ids + Assert.Equal(200, transport.Inbound.NakCount); + + clock.Advance(TimeSpan.FromSeconds(0.7)); + transport.Sweep(); + + byte[] nak = Assert.Single(sent); + PacketHeader header = PacketHeader.Unpack(nak); + // 114 ids (ReceiverData::GetNaks @ 0x005490C0 caps 0x72): the 114 + // LOWEST, ascending. + Assert.Equal((ushort)(4 + 114 * 4), header.DataSize); + Assert.Equal( + 114u, + BinaryPrimitives.ReadUInt32LittleEndian( + nak.AsSpan(PacketHeader.Size))); + for (int i = 0; i < 114; i++) + { + Assert.Equal( + (uint)(3 + i), + BinaryPrimitives.ReadUInt32LittleEndian( + nak.AsSpan(PacketHeader.Size + 4 + i * 4))); + } + + Assert.Equal(200, transport.Inbound.NakCount); + } + + // ===================================================================== + // The AD-51 reclaimed-word pool — tracker-level proofs. The shadow + // ISAAC is seeded identically; its draw order is the SERVER's word + // assignment (the server never draws for a cleartext reject). + // ===================================================================== + + [Fact] + public void RejectInOrder_OwnMisparkedWord_FeedsTheNextFreshDraw() + { + (InboundSequenceTracker tracker, _) = CreateTracker(9); + IsaacRandom shadow = MakeIsaac(TrackerSeed); + uint a = shadow.Next(); + uint b = shadow.Next(); + uint c = shadow.Next(); + + // ACE flushes a reject at fresh cleartext sequence 10 (no word), + // then encrypted 11, 12, 13 sealed with a, b, c. The reject + // arrives first, in order: the cleartext borrowed-id walk parks a + // word for 10 itself — the mis-park. + Assert.False(tracker.Admit(10, encrypted: false).Drop); + Assert.Equal(1, tracker.NakCount); + + tracker.OnCleartextRejectSequence(10); + Assert.Equal(0, tracker.NakCount); + Assert.Equal(1, tracker.ReclaimedWordCount); + + // WITHOUT the pool, 11 would draw the second word (b) and fail + // forever — the one-word-ahead desync. With it, 11 takes the + // reclaimed word (a): exactly the position ACE's stream sits on. + Assert.Equal(a, Admitted(tracker, 11)); + Assert.Equal(0, tracker.ReclaimedWordCount); + Assert.Equal(b, Admitted(tracker, 12)); + Assert.Equal(c, Admitted(tracker, 13)); + Assert.Equal(0, tracker.NakCount); + } + + [Fact] + public void RejectAfterHigherArrival_BubbleRealignsTheParkedChain() + { + (InboundSequenceTracker tracker, _) = CreateTracker(9); + IsaacRandom shadow = MakeIsaac(TrackerSeed); + uint a = shadow.Next(); + uint b = shadow.Next(); + uint c = shadow.Next(); + + // ACE: reject at 10 (cleartext, no word), then encrypted 11 sealed + // with a. The reject is delayed; 11 arrives FIRST: the gap walk + // parks a for the missing 10 and hands 11 the word b — which fails + // verification (ACE sealed 11 with a), so the session re-parks b + // beside 11. + InboundSequenceTracker.Admission eleven = + tracker.Admit(11, encrypted: true); + Assert.Equal(b, eleven.VerifyKey); + tracker.ReparkKey(11, b, eleven.VerifyKeyDrawOrder); + Assert.Equal(2, tracker.NakCount); // 10 and 11 both parked + + // The reject at 10 arrives late (cleartext, below the watermark). + // The reclaim removes 10's mis-park AND bubbles the chain above it + // down one word: 11's parked word becomes a (its true word), and + // the excess b joins the pool. + Assert.False(tracker.Admit(10, encrypted: false).Drop); + tracker.OnCleartextRejectSequence(10); + Assert.Equal(1, tracker.NakCount); + Assert.Equal(1, tracker.ReclaimedWordCount); + + // The retransmission of 11 decodes with its TRUE word. + Assert.Equal(a, Admitted(tracker, 11)); + Assert.Equal(0, tracker.NakCount); + + // And the next fresh packet takes the pooled b, then the stream + // continues on c — full realignment. + Assert.Equal(b, Admitted(tracker, 12)); + Assert.Equal(0, tracker.ReclaimedWordCount); + Assert.Equal(c, Admitted(tracker, 13)); + } + + [Fact] + public void RejectBodyIds_StayDiscarded_OnlyTheOwnSequenceReclaims() + { + (InboundSequenceTracker tracker, _) = CreateTracker(9); + IsaacRandom shadow = MakeIsaac(TrackerSeed); + uint s1 = shadow.Next(); + uint s2 = shadow.Next(); + uint s3 = shadow.Next(); + uint s4 = shadow.Next(); + uint s5 = shadow.Next(); + + // Encrypted 10, 11 (sealed s1, s2) are lost; encrypted 12 (s3) + // arrives: parks s1 beside 10, s2 beside 11, decodes with s3. + Assert.Equal(s3, Admitted(tracker, 12)); + Assert.Equal(2, tracker.NakCount); + + // ACE pruned 10 and 11; the reject arrives at fresh cleartext + // sequence 13 listing them. The BODY ids are the word-bearing + // case: their words were drawn on both sides, so the parked keys + // are consumed-in-place — discarded, never pooled (N2 behavior, + // unchanged). Only the reject's OWN sequence reclaims. + Assert.False(tracker.Admit(13, encrypted: false).Drop); + Span ids = stackalloc byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(ids, 10u); + BinaryPrimitives.WriteUInt32LittleEndian(ids.Slice(4), 11u); + tracker.OnRejectRetransmit(ids, count: 2); + tracker.OnCleartextRejectSequence(13); + + Assert.Equal(0, tracker.NakCount); + Assert.Equal(1, tracker.ReclaimedWordCount); // s4 — 13's mis-park + + // ACE's stream: s1, s2 consumed by the pruned 10, 11; s3 by 12; + // its next encrypted packet (14) seals with s4 — our pooled word. + Assert.Equal(s4, Admitted(tracker, 14)); + Assert.Equal(s5, Admitted(tracker, 15)); + Assert.Equal(0, tracker.ReclaimedWordCount); + } + + [Fact] + public void TwoInterleavedRejects_InOrder_PoolPreservesAlignment() + { + (InboundSequenceTracker tracker, _) = CreateTracker(9); + IsaacRandom shadow = MakeIsaac(TrackerSeed); + uint s1 = shadow.Next(); + uint s2 = shadow.Next(); + + // Rejects at fresh cleartext 10 and 11, then encrypted 12, 13 + // (ACE seals them with s1, s2 — it drew nothing for the rejects). + Assert.False(tracker.Admit(10, encrypted: false).Drop); + tracker.OnCleartextRejectSequence(10); + Assert.False(tracker.Admit(11, encrypted: false).Drop); + // 11's own park consumed the pooled word back; reclaiming it + // returns it to the pool — depth stays one through the pair. + tracker.OnCleartextRejectSequence(11); + Assert.Equal(0, tracker.NakCount); + Assert.Equal(1, tracker.ReclaimedWordCount); + + Assert.Equal(s1, Admitted(tracker, 12)); + Assert.Equal(s2, Admitted(tracker, 13)); + Assert.Equal(0, tracker.ReclaimedWordCount); + } + + [Fact] + public void TwoInterleavedRejects_Crossed_PoolDrainsInDrawOrder() + { + (InboundSequenceTracker tracker, _) = CreateTracker(9); + IsaacRandom shadow = MakeIsaac(TrackerSeed); + uint s1 = shadow.Next(); + uint s2 = shadow.Next(); + uint s3 = shadow.Next(); + uint s4 = shadow.Next(); + uint s5 = shadow.Next(); + + // ACE: rejects at 10 and 11 (no words), encrypted 12, 13 sealed + // with s1, s2. Both encrypted packets arrive FIRST: the walk parks + // s1, s2 beside the rejects; 12 and 13 draw s3, s4 — both fail + // verification (true words s1, s2) and re-park. + InboundSequenceTracker.Admission twelve = + tracker.Admit(12, encrypted: true); + Assert.Equal(s3, twelve.VerifyKey); + tracker.ReparkKey(12, s3, twelve.VerifyKeyDrawOrder); + InboundSequenceTracker.Admission thirteen = + tracker.Admit(13, encrypted: true); + Assert.Equal(s4, thirteen.VerifyKey); + tracker.ReparkKey(13, s4, thirteen.VerifyKeyDrawOrder); + Assert.Equal(4, tracker.NakCount); + + // The rejects arrive OUT of order — 11 first, then 10. Each + // reclaim bubbles the chain above it down one word. A naive FIFO + // would hand the excess words out in push order (s4 then s3); + // draw-order consumption is what keeps the pool aligned. + Assert.False(tracker.Admit(11, encrypted: false).Drop); + tracker.OnCleartextRejectSequence(11); + Assert.False(tracker.Admit(10, encrypted: false).Drop); + tracker.OnCleartextRejectSequence(10); + Assert.Equal(2, tracker.NakCount); // 12, 13 remain + Assert.Equal(2, tracker.ReclaimedWordCount); // s3, s4 + + // The retransmissions decode with their TRUE words (the bubble + // put s1 beside 12 and s2 beside 13). + Assert.Equal(s1, Admitted(tracker, 12)); + Assert.Equal(s2, Admitted(tracker, 13)); + Assert.Equal(0, tracker.NakCount); + + // Fresh packets drain the pool lowest-draw-order first: s3, then + // s4, then the wheel continues at s5. + Assert.Equal(s3, Admitted(tracker, 14)); + Assert.Equal(s4, Admitted(tracker, 15)); + Assert.Equal(0, tracker.ReclaimedWordCount); + Assert.Equal(s5, Admitted(tracker, 16)); + } + + // ===================================================================== + // Conformance against the N0 ACE-behaviour double (real WorldSession) + // ===================================================================== + + /// + /// The N4 win end-to-end: a real S2C loss now RECOVERS without test + /// hooks — the 0.6 s sweep NAKs the parked id, the model serves the + /// Retransmission from its cache, the parked key decodes it, the set + /// empties, and the cumulative ack resumes 2.0 s after the NAK. + /// + [Fact] + public void S2CLoss_NakRoundTrip_ModelRetransmission_AcksResume() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + session.TransportClockSource = + (transport.Clock.GetTimestamp, transport.Clock.Frequency); + try + { + session.Connect( + "testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + + var messages = new List(); + session.ServerMessageReceived += m => messages.Add(m.Message); + + // The link eats the datagram carrying "lost"; "marker" opens + // the gap and parks the missing id's key. + transport.Link.DropNext(LinkDirection.ServerToClient); + transport.Model.EnqueueGameMessage( + BuildServerMessage("lost"), GameMessageGroup.UIQueue); + transport.PumpServer(); + transport.Model.EnqueueGameMessage( + BuildServerMessage("marker"), GameMessageGroup.UIQueue); + transport.PumpServer(); + PumpUntil(session, () => session.Transport!.Inbound.NakCount == 1); + PumpUntil(session, () => messages.Contains("marker")); + + long naksBefore = session.Transport!.Stats.NaksSent; + long acksBefore = session.Transport.Stats.AcksSent; + int servedBefore = transport.Model.RetransmitsServed; + + // Past the 0.6 s gate the sweep emits the NAK; the model + // serves the cached datagram immediately (NetworkSession + // Retransmit :675-708 — cache-entry flags gain Retransmission, + // the ORIGINAL IsaacXor is reused). + transport.Clock.Advance(TimeSpan.FromSeconds(0.7)); + session.Tick(); + Assert.Equal(naksBefore + 1, session.Transport.Stats.NaksSent); + Assert.Equal(servedBefore + 1, transport.Model.RetransmitsServed); + + bool sawRetransmission = false; + foreach (byte[] datagram in transport.Model.SentDatagrams) + { + if ((PacketHeader.Unpack(datagram).Flags + & PacketHeaderFlags.Retransmission) != 0) + { + sawRetransmission = true; + } + } + + Assert.True(sawRetransmission); + + // The parked key decodes the retransmission; the message + // dispatches and the set empties. + PumpUntil(session, () => messages.Contains("lost")); + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.Equal(0, session.Transport.Stats.ChecksumFailures); + Assert.Equal(0, transport.Model.CrcDropCount); + + // The ack resumes 2.0 s after the NAK's stamp of the shared + // timestamp. + transport.Clock.Advance(TimeSpan.FromSeconds(2.1)); + session.Tick(); + Assert.True(session.Transport.Stats.AcksSent > acksBefore); + Assert.False(transport.Model.IsTerminated); + } + finally + { + session.Dispose(); + } + } + + /// + /// Prompt test (a)+(b) at system level: a sustained one-way S2C outage + /// ages the lost id past ACE's 120 s cache retention (C2S game traffic + /// keeps the session alive — NAKs never refresh ACE's timeout), so the + /// healed link delivers RejectRetransmits at FRESH cleartext sequences. + /// Every reject's own mis-parked word is reclaimed (including + /// retransmitted rejects for the ones lost during the outage), the + /// abandoned id inside the reject bodies stays discarded, and later + /// encrypted traffic decodes until the pool drains to zero — the + /// permanent one-word-ahead desync this slice exists to prevent. + /// + [Fact] + public void PrunedId_RejectAtFreshSequence_ReclaimKeepsTheStreamAligned() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + session.TransportClockSource = + (transport.Clock.GetTimestamp, transport.Clock.Frequency); + try + { + session.Connect( + "testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + + var messages = new List(); + session.ServerMessageReceived += m => messages.Add(m.Message); + + // Open the gap: "victim" is eaten, "marker" parks its id. + transport.Link.DropNext(LinkDirection.ServerToClient); + transport.Model.EnqueueGameMessage( + BuildServerMessage("victim"), GameMessageGroup.UIQueue); + transport.PumpServer(); + transport.Model.EnqueueGameMessage( + BuildServerMessage("marker"), GameMessageGroup.UIQueue); + transport.PumpServer(); + PumpUntil(session, () => session.Transport!.Inbound.NakCount == 1); + + // Total S2C outage. Our NAKs still reach the model (it serves + // the cached victim into the void every time) and per-step C2S + // chat keeps refreshing ACE's 60 s deadline — NAKs deliberately + // never do (NetworkSession.cs:283-308 returns before the + // refresh). + bool s2cBlocked = true; + transport.Link.Drop( + LinkDirection.ServerToClient, (_, _) => s2cBlocked); + + // 130 virtual seconds in 0.5 s steps: the victim's cache entry + // ages past the 120 s retention (prune runs every 5 s) and the + // post-prune NAKs start drawing RejectRetransmits at fresh + // sequences — several of them, all dropped, each consuming + // another fresh sequence (the interleaved-reject shape). + for (int step = 0; step < 260; step++) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); + session.SendTalk($"keepalive {step}"); + transport.PumpServer(); + session.Tick(); + if ((step & 15) == 0) + Thread.Sleep(1); + } + + Assert.False(transport.Model.IsTerminated); + Assert.True( + transport.Model.RetransmitsServed > 0, + "the outage should have served retransmits into the void"); + + // Heal. The next NAK sweep re-requests everything parked + // across the outage window (the victim, the TimeSync ids, the + // dropped rejects' fresh ids); the model retransmits its + // cached entries — including the CACHED rejects — and answers + // the pruned victim with a fresh reject. + s2cBlocked = false; + DateTime deadline = DateTime.UtcNow.AddSeconds(20); + while (session.Transport!.Inbound.NakCount > 0 + && DateTime.UtcNow < deadline) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(700)); + transport.PumpServer(); + session.Tick(); + Thread.Sleep(1); + } + + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.True( + session.Transport.Stats.RejectWordsReclaimed >= 1, + "at least one reject fresh-sequence mis-park must have been reclaimed"); + + bool modelSentReject = false; + foreach (byte[] datagram in transport.Model.SentDatagrams) + { + if ((PacketHeader.Unpack(datagram).Flags + & PacketHeaderFlags.RejectRetransmit) != 0) + { + modelSentReject = true; + } + } + + Assert.True(modelSentReject); + + // The victim is ABANDONED, not recovered: its parked word was + // discarded in place (the body-id half of AD-51). + Assert.DoesNotContain("victim", messages); + + // THE PROOF the hazard is closed: encrypted traffic keeps + // decoding until the reclaim pool fully drains, and beyond. + // Without the pool, the first fresh draw after the reject + // would sit one word ahead and every packet here would fail + // its checksum. + int drainTarget = + session.Transport.Inbound.ReclaimedWordCount + 3; + long checksumFailuresBefore = + session.Transport.Stats.ChecksumFailures; + for (int i = 0; i < drainTarget; i++) + { + transport.Model.EnqueueGameMessage( + BuildServerMessage($"post-heal {i}"), + GameMessageGroup.UIQueue); + transport.PumpServer(); + int expected = i; + PumpUntil( + session, + () => messages.Contains($"post-heal {expected}")); + } + + Assert.Equal( + checksumFailuresBefore, + session.Transport.Stats.ChecksumFailures); + Assert.Equal(0, session.Transport.Inbound.ReclaimedWordCount); + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.Equal(256, transport.Model.Crypto.Headroom); + Assert.False(transport.Model.IsTerminated); + } + finally + { + session.Dispose(); + } + } + + /// + /// Long-loss survival: a 10 s total S2C outage with a parked gap + /// produces NAKs on the 0.6 s cadence and ZERO acks (mutual + /// exclusivity), the model's 60 s timeout never fires, and when the + /// loss heals inside the window the gap resolves and the acks resume. + /// + [Fact] + public void LongLoss_NaksOnTheGateCadence_NoAcks_GapHealsInsideTheWindow() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + session.TransportClockSource = + (transport.Clock.GetTimestamp, transport.Clock.Frequency); + try + { + session.Connect( + "testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + + var messages = new List(); + session.ServerMessageReceived += m => messages.Add(m.Message); + + transport.Link.DropNext(LinkDirection.ServerToClient); + transport.Model.EnqueueGameMessage( + BuildServerMessage("lost"), GameMessageGroup.UIQueue); + transport.PumpServer(); + transport.Model.EnqueueGameMessage( + BuildServerMessage("marker"), GameMessageGroup.UIQueue); + transport.PumpServer(); + PumpUntil(session, () => session.Transport!.Inbound.NakCount == 1); + + long naksBefore = session.Transport!.Stats.NaksSent; + long acksBefore = session.Transport.Stats.AcksSent; + + // 10 s of total S2C loss in 0.25 s frames: the NAK cadence is + // gate-quantized to every 0.75 s (the first frame past each + // 0.6 s gate) — 13 emissions, zero acks. + bool s2cBlocked = true; + transport.Link.Drop( + LinkDirection.ServerToClient, (_, _) => s2cBlocked); + for (int step = 0; step < 40; step++) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(250)); + transport.PumpServer(); + session.Tick(); + if ((step & 7) == 0) + Thread.Sleep(1); + } + + long naksDuringWindow = + session.Transport.Stats.NaksSent - naksBefore; + Assert.InRange(naksDuringWindow, 11, 15); + Assert.Equal(acksBefore, session.Transport.Stats.AcksSent); + Assert.False(transport.Model.IsTerminated); + + // Heal: the next NAK round-trips, the parked key decodes the + // retransmission, and the ack resumes 2.0 s after that NAK. + s2cBlocked = false; + DateTime deadline = DateTime.UtcNow.AddSeconds(15); + while ((session.Transport.Inbound.NakCount > 0 + || !messages.Contains("lost")) + && DateTime.UtcNow < deadline) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(700)); + transport.PumpServer(); + session.Tick(); + Thread.Sleep(1); + } + + Assert.Contains("lost", messages); + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.Equal(0, session.Transport.Stats.ChecksumFailures); + + transport.Clock.Advance(TimeSpan.FromSeconds(2.1)); + session.Tick(); + Assert.True(session.Transport.Stats.AcksSent > acksBefore); + Assert.False(transport.Model.IsTerminated); + } + finally + { + session.Dispose(); + } + } + + // ===================================================================== + // The loss soak — the approved-plan capstone + // ===================================================================== + + /// + /// 2% seeded random loss in BOTH directions across 10,000 game + /// messages (5,000 each way) on the virtual clock: zero message loss + /// in either direction, the model's 256-key crypto headroom intact + /// throughout (no C2S re-key or unrequested resend ever), and every + /// transport ledger converged at the end — NAK set empty, reclaim pool + /// empty, no pending resends, and the sent-packet cache holding at + /// most the watermark entry (ACE acks the last RECEIVED sequence and + /// retail's Flush prunes STRICTLY below it, so one entry is the + /// retail-faithful steady state). The session survives the whole run. + /// + [Fact] + public void LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + session.TransportClockSource = + (transport.Clock.GetTimestamp, transport.Clock.Frequency); + try + { + session.Connect( + "testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + + int s2cReceived = 0; + session.ServerMessageReceived += m => + { + if (m.Message.StartsWith("s2c ", StringComparison.Ordinal)) + s2cReceived++; + }; + + // Count the soak's own C2S messages by marker: the convergence + // trickle below shares the dispatch stream, and a bare count + // could hide one lost soak message behind one extra trickle + // message. + int c2sDispatched = 0; + transport.Model.MessageDispatched += body => + { + if (body.AsSpan().IndexOf("c2s "u8) >= 0) + c2sDispatched++; + }; + + transport.Link.RandomLoss( + LinkDirection.ClientToServer, 0.02, seed: 0x5EED0001); + transport.Link.RandomLoss( + LinkDirection.ServerToClient, 0.02, seed: 0x5EED0002); + + const int MessagesEachWay = 5_000; + for (int i = 0; i < MessagesEachWay; i++) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(25)); + session.SendTalk($"c2s {i}"); + transport.Model.EnqueueGameMessage( + BuildServerMessage($"s2c {i}"), + GameMessageGroup.UIQueue); + transport.PumpServer(); + session.Tick(); + + if ((i & 15) == 0) + Thread.Sleep(1); + + if (i % 500 == 0) + { + // The C2S keystream discipline holds the whole way: + // resends reuse their original key and nothing resends + // unrequested, so ACE's search window never ERODES. A + // sample can land while a loss is mid-heal — the lost + // packet's key sits parked in ACE's xors set until our + // resend un-parks it (CryptoSystem.cs:19-29), so the + // instantaneous floor is a handful below 256; the + // exact-256 pin is the converged assert below. + Assert.True( + transport.Model.Crypto.Headroom >= 250, + $"headroom {transport.Model.Crypto.Headroom} at " + + $"message {i} — the search window is eroding"); + Assert.False(transport.Model.IsTerminated); + } + } + + // Convergence phase 1: keep a C2S trickle flowing until every + // soak message has landed on both sides. ACE's NAK is + // ARRIVAL-driven (desired + 2 <= arrived, 1 s rate limit — + // campaign §3 row 1: "a quiet client is never NAKed", and our + // exact-flags acks are exempt from its gap detection), so a + // burst tail lost just before the client goes quiet is only + // recoverable once further sequenced C2S traffic arrives. Real + // clients keep talking; the trickle models that. + int trickle = 0; + DateTime deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline + && (s2cReceived != MessagesEachWay + || c2sDispatched != MessagesEachWay)) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); + session.SendTalk($"trickle {trickle++}"); + transport.PumpServer(); + session.Tick(); + Thread.Sleep(1); + } + + // Convergence phase 2: quiet drain. Acks keep flowing on the + // virtual clock and the ledgers empty out. The one wrinkle is + // a trickle tail lost on the wire: ACE never NAKs a quiet + // client, so a stuck cache entry needs fresh sequenced C2S + // traffic (two arrivals) before ACE's gap detection can fire — + // an occasional healer send, only while the cache is stuck. + int quietIterations = 0; + while (DateTime.UtcNow < deadline) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); + if (session.Transport!.Outbound.CacheDepth > 1 + && ++quietIterations % 8 == 0) + { + session.SendTalk($"trickle {trickle++}"); + } + + transport.PumpServer(); + session.Tick(); + Thread.Sleep(1); + + if (s2cReceived == MessagesEachWay + && c2sDispatched == MessagesEachWay + && session.Transport.Inbound.NakCount == 0 + && session.Transport.Inbound.ReclaimedWordCount == 0 + && session.Transport.Outbound.PendingResendCount == 0 + && session.Transport.Outbound.CacheDepth <= 1 + && transport.Model.OutOfOrderPacketCount == 0) + { + break; + } + } + + // Zero message loss, both directions. + string ledger = + $"s2c={s2cReceived} " + + $"c2s={c2sDispatched} " + + $"nak-set={session.Transport!.Inbound.NakCount} " + + $"reclaim={session.Transport.Inbound.ReclaimedWordCount} " + + $"pending={session.Transport.Outbound.PendingResendCount} " + + $"cache={session.Transport.Outbound.CacheDepth} " + + $"ooo={transport.Model.OutOfOrderPacketCount} " + + $"fraggate={transport.Model.FragmentGateBufferCount} " + + $"cksumfail={session.Transport.Stats.ChecksumFailures} " + + $"dups={session.Transport.Stats.InboundDupsDropped} " + + $"naks-sent={session.Transport.Stats.NaksSent} " + + $"resends={session.Transport.Stats.ResendsSent} " + + $"served={transport.Model.RetransmitsServed} " + + $"headroom={transport.Model.Crypto.Headroom} " + + $"terminated={transport.Model.IsTerminated}"; + Assert.True( + s2cReceived == MessagesEachWay, + $"S2C loss: {ledger}"); + Assert.True( + c2sDispatched == MessagesEachWay, + $"C2S loss: {ledger}"); + + // The loss was real and both recovery directions fired. + Assert.True( + transport.Link.DroppedCount(LinkDirection.ClientToServer) > 0); + Assert.True( + transport.Link.DroppedCount(LinkDirection.ServerToClient) > 0); + Assert.True(session.Transport!.Stats.ResendsSent > 0); + Assert.True(session.Transport.Stats.NaksSent > 0); + Assert.True(transport.Model.RetransmitsServed > 0); + + // Ledgers converged. + Assert.Equal(256, transport.Model.Crypto.Headroom); + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.Equal(0, session.Transport.Inbound.ReclaimedWordCount); + Assert.Equal(0, session.Transport.Outbound.PendingResendCount); + Assert.True( + session.Transport.Outbound.CacheDepth <= 1, + $"cache depth {session.Transport.Outbound.CacheDepth} — " + + "only the watermark entry may remain"); + Assert.Equal(0, transport.Model.OutOfOrderPacketCount); + Assert.Equal(0, transport.Model.FragmentGateBufferCount); + Assert.Equal(0, session.Transport.Stats.ChecksumFailures); + + // Alive at the end. + Assert.False(transport.Model.IsTerminated); + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + } + finally + { + session.Dispose(); + } + } + + // ===================================================================== + // Fixture helpers + // ===================================================================== + + private static (ReliableTransport Transport, VirtualClock Clock, + List Sent) CreateTransport() + { + var virtualClock = new VirtualClock(); + var sent = new List(); + var transport = new ReliableTransport( + MakeIsaac(ClientSeed), + MakeIsaac(ServerSeed), + (ushort)ClientId, + SessionIteration, + datagram => sent.Add(datagram.ToArray()), + new TransportClock( + virtualClock.GetTimestamp, + virtualClock.Frequency)); + return (transport, virtualClock, sent); + } + + private static (InboundSequenceTracker Tracker, TransportStats Stats) + CreateTracker(uint initialWatermark) + { + var stats = new TransportStats(); + return ( + new InboundSequenceTracker( + MakeIsaac(TrackerSeed), stats, initialWatermark), + stats); + } + + private static void Admit(ReliableTransport transport, uint sequence) + { + InboundSequenceTracker.Admission admission = + transport.Inbound.Admit(sequence, encrypted: true); + Assert.False(admission.Drop); + } + + private static uint Admitted(InboundSequenceTracker tracker, uint sequence) + { + InboundSequenceTracker.Admission admission = + tracker.Admit(sequence, encrypted: true); + Assert.False(admission.Drop); + Assert.NotNull(admission.VerifyKey); + return admission.VerifyKey!.Value; + } + + private static byte[] MakeMessage(byte marker) => + new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 }; + + private static IsaacRandom MakeIsaac(uint seed) + { + Span seedBytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); + return new IsaacRandom(seedBytes); + } + + private static void PumpUntil(WorldSession session, Func condition) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition() && DateTime.UtcNow < deadline) + { + session.Tick(); + Thread.Sleep(5); + } + + Assert.True(condition(), "condition not reached before the deadline"); + } + + private static byte[] BuildServerMessage(string text) + { + var writer = new PacketWriter(64 + text.Length); + writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 + writer.WriteString16L(text); + writer.WriteUInt32(1); // ChatMessageType + return writer.ToArray(); + } +}