feat(net): N4 - client NAK emission + RejectRetransmit reclaim
Campaign N slice N4 completes the AckNakScheduler NAK branch and closes the ACE cleartext-reject keystream hazard - the slice that makes S2C loss actually RECOVER. NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0): - One cleartext exact-flags RequestRetransmit per sweep behind the STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s and vice versa (landmine #7). - Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E); header Sequence borrowed from highestIDSent_ without incrementing; cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) - and a NAK never refreshes ACE's 60 s timeout. - Control-header rule decided once for BOTH ack and NAK: Time = the interval id, Iteration = the session iteration, matching retail's shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84). ACE reads neither field inbound. - Gate ticks now round instead of truncate: 0.6 has no exact double form, and truncation opened the strict gate exactly AT the boundary. RejectRetransmit reclaim (divergence register AD-51, ACE adaptation): - ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748) - the one place ACE breaks retail's gap-walk invariant that every missing id was word-bearing (retail cleartext always borrows live sequences). Unhandled, the gap walk parks a word for the reject's id and the inbound stream runs permanently one word ahead - the N2 desync class reintroduced through the reject path. - Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes the mis-park, shifts every later-drawn parked word down one position (per-word draw ordinals; ascending wrap-safe id <=> ascending draw order), and pools the excess word, consumed lowest-draw-order-first ahead of fresh ISAAC draws. Exact for any number of interleaved rejects in ANY arrival order - a plain reclaim FIFO is not: a reject arriving after a higher encrypted arrival crosses the parked chain, and two out-of-order rejects pool their excess words out of draw order (both orderings pinned by tests). - Reject BODY ids keep N2's discard: word-bearing server-side, consumed-in-place. The pool is provably empty against retail servers. N3 advisories folded (all five): honest transitional-state wording (the empty N3 NAK branch could silently disconnect a loopback session at ACE's 60 s timeout, witness [net-tick] acks/s=0), the ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0) citation, the FlowQueue::Empty pump-order wording (TransmitNaks -> TransmitAcks -> TransmitNewPackets with the interval increment LAST @ 0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the Time/Iteration rule above, and the stale WorldSession budget-break comment rewritten to the sweep reality. Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3 pins): strict-gate boundary, shared timestamp both directions, NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served retransmission round trip, five tracker reclaim proofs, the 130 s virtual prune -> fresh-sequence reject system test (victim abandoned, later traffic decodes, pool drains to zero), 10 s long-loss survival (NAKs on the gate cadence, zero acks, heal inside the window), and the capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero message loss both ways, ACE crypto headroom 256 at convergence, every ledger drained (cache at the single watermark entry - retail's Flush prunes STRICTLY below the ack). Full solution Release: 9,758 passed / 5 skipped. Connected world-lifecycle gate PASS (logs/connected-world-gate-20260729-150238); canonical nine-stop soak PASS (logs/connected-r6-soak-20260729-150856). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e9686401bc
commit
852a59e388
10 changed files with 1467 additions and 109 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 | — | |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ using AcDream.Core.Net.Packets;
|
|||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N3: retail's per-frame ack/NAK arbitration
|
||||
/// Campaign N Slices N3+N4: retail's per-frame ack/NAK arbitration
|
||||
/// (<c>ClientNet::ProcessConnection @ 0x00545450</c>), replacing the
|
||||
/// Phase 4.9 per-packet reflex ack. Retail never acks per packet —
|
||||
/// <c>SharedNet::EnqueuePak @ 0x00543B10</c> is the ONLY
|
||||
|
|
@ -17,14 +17,13 @@ namespace AcDream.Core.Net.Transport;
|
|||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>NAK set non-empty → the NAK branch
|
||||
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>, 0.6 s gate on the SAME
|
||||
/// timestamp) and NO ack this sweep. N4 emits the
|
||||
/// <c>RequestRetransmit</c> 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.</item>
|
||||
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>): when
|
||||
/// <c>now − sharedTimestamp > 0.6 s</c> — 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 <c>RequestRetransmit</c> carrying up to
|
||||
/// 114 parked ids ascending (<c>ReceiverData::GetNaks @ 0x005490C0</c>,
|
||||
/// cap 0x72), stamp the shared timestamp, and send NO ack this sweep.</item>
|
||||
/// <item>Else, when <c>now − sharedTimestamp ≥ 2.0 s</c>: ONE cumulative
|
||||
/// <c>AckSequence</c> carrying the tracker's <c>highestIDReceived_</c>
|
||||
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>), then
|
||||
|
|
@ -32,23 +31,47 @@ namespace AcDream.Core.Net.Transport;
|
|||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The gate is armed at construction: retail's <c>ReceiverData::Init</c>
|
||||
/// (@ 0x00548EF0, the <c>timeStamp_ = Timer::cur_time</c> 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 <c>sendAck</c> 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: <c>[net-tick] acks/s=0</c>), 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125):
|
||||
/// flags EXACTLY <see cref="PacketHeaderFlags.AckSequence"/> — 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
|
||||
/// <c>Sequence</c> borrowed from <c>highestIDSent_</c> without incrementing,
|
||||
/// <c>Id</c> = the session client id, <c>Time</c>/<c>Iteration</c> zero.
|
||||
/// The gate is armed at construction: retail's
|
||||
/// <c>ReceiverData::SharedInit @ 0x00548EF0</c> (reached from
|
||||
/// <c>ReceiverData::Init @ 0x00548FA0</c>; the <c>timeStamp_ =
|
||||
/// Timer::cur_time</c> 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 <c>sendAck</c> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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 (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>,
|
||||
/// the stack build at 0x00547A84), which stamps <c>Time</c> =
|
||||
/// <c>CurLocalInterval_.intervalID_</c> and <c>Iteration</c> = the
|
||||
/// receiver iteration — so the ack and the NAK here stamp
|
||||
/// <see cref="TransportClock.IntervalId"/> 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 <c>Sequence</c> borrowed from
|
||||
/// <c>highestIDSent_</c> without incrementing. Flags are EQUALITY-exact,
|
||||
/// never an OR: the ack must be exactly
|
||||
/// <see cref="PacketHeaderFlags.AckSequence"/> (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
|
||||
/// <see cref="PacketHeaderFlags.RequestRetransmit"/> (landmine #6 — ACE
|
||||
/// honours only the cleartext form, NetworkSession.cs:283-284, and a NAK
|
||||
/// never refreshes ACE's 60 s timeout).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -64,17 +87,31 @@ internal sealed class AckNakScheduler
|
|||
/// 2.0 at 0x00543B32).</summary>
|
||||
public const double AckGateSeconds = 2.0;
|
||||
|
||||
/// <summary>Retail's NAK gate
|
||||
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>, the x87 compare against
|
||||
/// 0.6 at 0x00543BF8/0x00543C03 — strictly greater opens it).</summary>
|
||||
public const double NakGateSeconds = 0.6;
|
||||
|
||||
/// <summary>Retail's NAK-list cap
|
||||
/// (<c>ReceiverData::GetNaks @ 0x005490C0</c> clamps the returned count
|
||||
/// at 0x72 = 114; ACE's own inbound cap is 115, NetworkSession.cs:381).</summary>
|
||||
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<uint> _nakScratch = new();
|
||||
|
||||
/// <summary>THE shared timestamp (<c>ReceiverData::timeStamp_</c>
|
||||
/// @ +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.</summary>
|
||||
/// @ +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.</summary>
|
||||
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
|
|||
/// <summary>
|
||||
/// Build and send the one cumulative <c>AckSequence</c>
|
||||
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>: mask 0x4000, 4-byte
|
||||
/// payload = <c>highestIDReceived_</c>), byte-shaped exactly like the
|
||||
/// pre-N3 reflex ack except the VALUE is the cumulative watermark.
|
||||
/// payload = <c>highestIDReceived_</c>).
|
||||
/// </summary>
|
||||
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++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build and send the one <c>RequestRetransmit</c>
|
||||
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>: mask 0x1000, body =
|
||||
/// u32 count + count × u32 ids, data length <c>4·count + 4</c> — the
|
||||
/// <c>m_cbData</c> store at 0x00543C3E — ids ascending from the AVL
|
||||
/// walk, count capped at 114 by <c>ReceiverData::GetNaks
|
||||
/// @ 0x005490C0</c>). Cleartext with flags EXACTLY
|
||||
/// <c>RequestRetransmit</c> (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.
|
||||
/// </summary>
|
||||
private void EmitRequestRetransmit()
|
||||
{
|
||||
_inbound.CopyNakkedSequencesAscending(_nakScratch, MaxNakIdsPerPacket);
|
||||
int count = _nakScratch.Count;
|
||||
int bodyLength = sizeof(uint) + count * sizeof(uint);
|
||||
|
||||
Span<byte> 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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,43 @@ namespace AcDream.Core.Net.Transport;
|
|||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The reclaimed-word pool (N4, ACE-only machinery — register AD-51).</b>
|
||||
/// Retail never assigns fresh sequences to cleartext packets (acks and NAKs
|
||||
/// borrow; <c>FlushPackets</c> 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: <c>RejectRetransmit</c> 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
|
||||
/// (<see cref="OnCleartextRejectSequence"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item>remove the reject's own parked entry — its word was mis-drawn;</item>
|
||||
/// <item>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);</item>
|
||||
/// <item>push the excess word — the newest in the shifted chain — into the
|
||||
/// reclaim pool, consumed lowest-draw-order-first by the next fresh
|
||||
/// draws.</item>
|
||||
/// </list>
|
||||
/// Draw order is tracked per parked word (<see cref="ParkedWord"/>) 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
internal readonly record struct ParkedWord(uint Word, ulong DrawOrder);
|
||||
|
||||
/// <summary>Retail <c>m_SeqIDsWeNAKed</c>: missing sequence → the
|
||||
/// pre-drawn keystream word parked for it.</summary>
|
||||
private readonly SortedDictionary<uint, uint> _nakSet = new();
|
||||
private readonly SortedDictionary<uint, ParkedWord> _nakSet = new();
|
||||
|
||||
/// <summary>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 <see cref="NextWord"/> before any fresh
|
||||
/// ISAAC draw. Provably empty against a retail server.</summary>
|
||||
private readonly PriorityQueue<uint, ulong> _reclaimedWords = new();
|
||||
|
||||
/// <summary>Scratch for the reject bubble-shift — reused, never
|
||||
/// allocated on the steady-state path.</summary>
|
||||
private readonly List<uint> _rejectShiftScratch = new();
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private ulong _drawOrdinal;
|
||||
|
||||
/// <summary>Retail <c>highestIDReceived_</c> — the newest sequence ever
|
||||
/// admitted (NOT "highest fully processed"; the gap walk advances it
|
||||
/// past holes).</summary>
|
||||
public uint HighestIdReceived { get; private set; }
|
||||
|
||||
/// <summary>Missing ids currently carrying a parked key — the value
|
||||
/// N4's <c>RequestRetransmit</c> emission drains.</summary>
|
||||
/// <summary>Missing ids currently carrying a parked key — the set the
|
||||
/// N4 <c>RequestRetransmit</c> emission copies (never drains: entries
|
||||
/// leave only on parked-key decode or RejectRetransmit abandonment).</summary>
|
||||
public int NakCount => _nakSet.Count;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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. <see cref="Drop"/> —
|
||||
/// discard without touching the checksum. Otherwise verify with
|
||||
/// <see cref="VerifyKey"/>: the keystream word for encrypted packets,
|
||||
/// null for the additive cleartext form.
|
||||
/// null for the additive cleartext form. <see cref="VerifyKeyDrawOrder"/>
|
||||
/// travels with the key so a checksum-failure re-park
|
||||
/// (<see cref="ReparkKey"/>) keeps the word's draw position — the AD-51
|
||||
/// reject reclaim needs it to know which parked entries a mis-park
|
||||
/// shifted.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -196,13 +273,16 @@ internal sealed class InboundSequenceTracker
|
|||
/// (<c>ProcessPacket @ 0x00544790</c> tail,
|
||||
/// <c>AddNakked(seq, &key)</c>) so the byte-identical retransmission
|
||||
/// decodes with the same word. Idempotent like retail's AddNakked.
|
||||
/// <paramref name="drawOrder"/> is the admission's
|
||||
/// <see cref="Admission.VerifyKeyDrawOrder"/> — the word keeps its draw
|
||||
/// position across the re-park.
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy the NAKed ids in ascending raw-uint order — the same in-order
|
||||
/// enumeration retail's AVL yields (<c>ReceiverData::GetNaks
|
||||
/// @ 0x005490C0</c> walks it ascending for the ≤114-id NAK list). N4
|
||||
/// adds the cap; this is the simple full copy.
|
||||
/// Copy up to <paramref name="maxCount"/> 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
|
||||
/// (<c>ReceiverData::GetNaks @ 0x005490C0</c> 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.
|
||||
/// </summary>
|
||||
public void CopyNakkedSequencesAscending(List<uint> destination)
|
||||
public void CopyNakkedSequencesAscending(
|
||||
List<uint> destination,
|
||||
int maxCount = int.MaxValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
destination.Clear();
|
||||
foreach (uint sequence in _nakSet.Keys)
|
||||
{
|
||||
if (destination.Count >= maxCount)
|
||||
break;
|
||||
destination.Add(sequence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AD-51 (N4): a VALIDATED cleartext <c>RejectRetransmit</c> arrived at
|
||||
/// <paramref name="sequence"/> — 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:
|
||||
/// <list type="number">
|
||||
/// <item>remove the reject's parked entry;</item>
|
||||
/// <item>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;</item>
|
||||
/// <item>push the chain's newest word into the reclaim pool for the
|
||||
/// next fresh draw (lowest draw order first).</item>
|
||||
/// </list>
|
||||
/// Idempotent: a retransmitted reject (flags
|
||||
/// <c>RejectRetransmit|Retransmission</c>, 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 (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>
|
||||
/// sequences only reliable packets; control emissions borrow).
|
||||
/// </summary>
|
||||
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<uint, ParkedWord> 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++;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private ParkedWord NextWord()
|
||||
{
|
||||
if (_reclaimedWords.TryDequeue(out uint word, out ulong order))
|
||||
return new ParkedWord(word, order);
|
||||
|
||||
return new ParkedWord(_inboundIsaac.Next(), ++_drawOrdinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -251,7 +408,7 @@ internal sealed class InboundSequenceTracker
|
|||
if (_nakSet.ContainsKey(sequence))
|
||||
return;
|
||||
|
||||
_nakSet.Add(sequence, _inboundIsaac.Next());
|
||||
_nakSet.Add(sequence, NextWord());
|
||||
_stats.KeysParked++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ internal sealed class ReliableTransport : IDisposable
|
|||
IsaacRandom outboundIsaac,
|
||||
IsaacRandom inboundIsaac,
|
||||
ushort sessionClientId,
|
||||
ushort sessionIteration,
|
||||
DatagramSendDelegate send,
|
||||
TransportClock? clock = null,
|
||||
ArrayPool<byte>? 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
|
|||
|
||||
/// <summary>
|
||||
/// One transport pump: interval clock forward, NAK-xor-ack arbitration,
|
||||
/// pending NAKed resends out, acked cache entries pruned. Pump order per
|
||||
/// retail <c>FlowQueue::Empty @ 0x00548A20</c>: the interval clock, then
|
||||
/// the control-packet arbitration (<c>ClientNet::ProcessConnection
|
||||
/// @ 0x00545450</c> 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
|
||||
/// <c>FlowQueue::Empty @ 0x00548A20</c> drains
|
||||
/// <c>TransmitNaks → TransmitAcks → TransmitNewPackets</c> and advances
|
||||
/// the interval clock LAST (the 0.5 s walk +
|
||||
/// <c>IncrementLocalInterval</c> at 0x00548A9D); our Sweep advances the
|
||||
/// clock FIRST. The divergence is cosmetic against ACE — it only shifts
|
||||
/// which interval id lands in <c>Header.Time</c> 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.
|
||||
/// </summary>
|
||||
public void Sweep()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ internal sealed class TransportStats
|
|||
/// only ack construction site; there is no per-packet ack).</summary>
|
||||
public long AcksSent;
|
||||
|
||||
/// <summary>N4: <c>RequestRetransmit</c> packets emitted by the 0.6 s
|
||||
/// NAK branch (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c> →
|
||||
/// <c>ReceiverData::GetNaks @ 0x005490C0</c>, ≤114 ids each).</summary>
|
||||
public long NaksSent;
|
||||
|
||||
/// <summary>N4: mis-parked keystream words reclaimed from validated
|
||||
/// cleartext <c>RejectRetransmit</c> sequences (the AD-51 ACE
|
||||
/// adaptation; always zero against a retail server).</summary>
|
||||
public long RejectWordsReclaimed;
|
||||
|
||||
/// <summary>Live sent-packet cache depth — the N5 watchdog value
|
||||
/// (<c>cache=N</c> in <c>[net-tick]</c>; the cache is unbounded like
|
||||
/// retail's, so depth is the health signal, not a cap).</summary>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1: one reliable-transport pump slice (retail
|
||||
/// N1+N3+N4: one reliable-transport pump slice (retail
|
||||
/// <c>PacketController::UseTime @ 0x005410D0</c> shape): interval clock
|
||||
/// forward, pending NAKed resends out, acked cache pruned. Gated on
|
||||
/// negotiation — ACE's <c>Session.CheckState</c> 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
|
||||
/// <c>Session.CheckState</c> silently discards pre-negotiation control
|
||||
/// traffic (campaign landmine #8), and the transport does not exist
|
||||
/// before the ISAAC seeds do.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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, <c>Time</c>/<c>Iteration</c> zero.
|
||||
/// client id, and the N4 control-header rule: <c>Time</c> = the
|
||||
/// interval id at emission and <c>Iteration</c> = the session
|
||||
/// iteration (retail's shared header build at 0x00547A84).
|
||||
/// </summary>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
1017
tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs
Normal file
1017
tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue