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();
+ }
+}