From f9c5e47e7f1f8aafa4b9eb0e820dba8bdac3ab39 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 17:20:12 +0200 Subject: [PATCH] feat(net): N6 - ConnectResponse retransmit + fragment assembler eviction Campaign N Slice N6, the final implementation slice. ConnectResponse handshake retransmit: - While the connection is unconfirmed, the Connect character-list pump resends the IDENTICAL cleartext ConnectResponse (same sequence 1, same cookie, the one encoded datagram - no new outbound state) on retail's strict 0.333333333 s gate. Retail: ClientNet::ProcessConnection @ 0x00545450, case cs_ConnectionRequestAcked @ 0x0054547B (the constant load at 0x00545481; the mask-0x41 strictly-greater x87 test at 0x0054548C); ClientNet::SendConnectAck @ 0x005440F0 re-stamps lastSentHandshake_ (0x00544102) and rebuilds the same cookie packet. - Confirmation = the first checksum-valid post-negotiation packet whose header lacks the ConnectRequest flag: retail's cs_ConnectionRequestAcked -> cs_Connected edge (ClientNet::ProcessPacket @ 0x00545100, the 0x40000 exclusion at 0x0054514E, SetConnectionState(..., 5) at 0x00545160). - The cadence rides the TransportClock (virtual-clock testable through TransportClockSource); the Connect deadline stays wall-clock. - ACE safety pinned against the N0 model: a duplicate while still AuthConnectResponse re-routes idempotently through NetworkManager's pre-route; after acceptance CheckState clause 2 drops it pre-CRC at zero keystream cost. - Pre-N6, one lost ConnectResponse was a hang to the Connect deadline; the N5 decorator deliberately arms after this window, so nothing covered it. FragmentAssembler eviction (divergence register row AD-52): - Partials evict 60 s after their last ACCEPTED fragment; the stamp refreshes on every new fragment (retail's re-stamp rule, ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00), so a merely-slow partial can never age out - 60 s is a floor, not a tunable. Swept from ReliableTransport.Sweep on retail's 5 s flush cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the gate at 0x0054A3DC; per-entry ArrivedEphInfo::fTimedOut @ 0x0054AE30). N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state; the TTL reclaims it. - A 64-entry completed-sequence ring drops late duplicate fragments of already-completed messages instead of allocating a fresh partial that can never complete (the completed-then-duplicate leak). Fold-ins: - N5 review LOW-5: NetProbeTests + LossyTransportDecoratorTests (the static NetDiagnostics / Console.SetOut mutators) share one DisableParallelization xunit collection so they never run alongside classes constructing WorldSession. - Campaign section 9: N6 ledger row recorded; N5 row verified carrying 4e290f00. Gates: 757 Core.Net Release tests green (10 new); full solution Release green (0 failures / 5 skips); connected lifecycle gate PASS; the N5-strengthened connected loss gate PASS on its first live run (2%/seed 1: dropped out=3 in=10, resends=1 nak-in=1 nak-out=5, cksum-fail=0 sanity-drop=0 uncached-nak=0). Co-Authored-By: Claude Sonnet 5 --- .../retail-divergence-register.md | 3 +- .../2026-07-29-network-transport-campaign.md | 2 +- .../Packets/FragmentAssembler.cs | 149 ++++++++- .../Transport/ReliableTransport.cs | 35 ++- src/AcDream.Core.Net/WorldSession.cs | 83 ++++- tests/AcDream.Core.Net.Tests/NetProbeTests.cs | 1 + .../NetProcessStaticsCollection.cs | 25 ++ .../Packets/FragmentAssemblerTests.cs | 122 ++++++++ .../ConnectResponseRetransmitTests.cs | 289 ++++++++++++++++++ .../Transport/LossyTransportDecoratorTests.cs | 1 + 10 files changed, 691 insertions(+), 19 deletions(-) create mode 100644 tests/AcDream.Core.Net.Tests/NetProcessStaticsCollection.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/ConnectResponseRetransmitTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 37f118fd..0fd7b20b 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) — 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) +## 2. Adaptation (AD) — 44 rows (AD-52 filed 2026-07-29 at Campaign N slice N6 — the fragment-assembler 60 s partial TTL + completed-sequence ring; 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 | |---|---|---|---|---|---| @@ -71,6 +71,7 @@ accepted-divergence entries (#96, #49, #50). | 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-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | 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 a4c457de..b87e0341 100644 --- a/docs/plans/2026-07-29-network-transport-campaign.md +++ b/docs/plans/2026-07-29-network-transport-campaign.md @@ -257,4 +257,4 @@ verbatim in each implementer prompt. | 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 | `852a59e3` — **Opus review PASS** (reclaim invariant attacked from five angles, held; NAK fidelity verified to the x87 masks; AP-125 filed + F1 false-arithmetic wording + F5 ordinal sentinel fixed in the acceptance commit; F3 Iteration-on-fresh-sends folds into N5) | 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 | complete | `4e290f00` — **Opus review PASS** (structural absence + arming gate + un-gameability + teardown all verified; ledger arithmetic reconciled). Acceptance folded in the review's gate strengthenings: per-direction recovery conjunction + the three keystream-health invariants (cksum-fail/sanity-drop/uncached-nak == 0) + the EnterWorldBody unrecoverable-tail caveat. Revert: `git revert 4e290f00d86ebd320d2da609fd7cd15d7175c4f0`. Test-collection hygiene (static NetDiagnostics mutation) folds into N6 | Loss observability + the LossyTransportDecorator + the connected loss gate — the permanent removal of the loopback blindness (§1). `[net-tick]` gains `resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s reclaim/s cache= nakset=` (window deltas mirroring acks/s; `TransportStats` gains `RejectsReceived`; string work probe-gated, counters unconditional) and `WorldSession.Dispose` emits one cumulative `[net-final]` totals line so the gate asserts exact counters, not rounded rates. N4-review F3 folded: fresh reliable sends stamp `Iteration` = the session iteration through the same shared retail header build already cited for `Time` and the N4 control packets (`FlowQueue::TransmitNewPackets @ 0x00547A60`, the build at 0x00547A84/0x00547AA8) — the control-header rule now holds across all three send shapes; ACE reads neither field inbound. `Transport/LossyTransportDecorator`: deterministic seeded per-direction loss (`ACDREAM_NET_DROP_PCT`/`_SEED`/`_DIR` via `NetDiagnostics` typed properties, Rule 5), armed only after the first ENCRYPTED outbound datagram is forwarded (parse-free flags-word check — the cleartext handshake always survives; handshake loss belongs to N6), structurally absent at 0% (`WrapIfConfigured` returns the raw transport; the default factory is the only production seam). The logoff-confirmation wait now runs the transport sweep — retail's pump (`Client::UseTime @ 0x00411C40`) never stops before `LogOffServer`, and the loss gate exposed that a dropped S2C confirmation was gap-detected but never NAKed during `Dispose`. `tools/run-connected-loss-gate.ps1` (default 2%/seed 1) runs the standard lifecycle route through the decorator vs local ACE and FAILS unless `[net-final]` shows resends>0 OR nak-out>0 OR nak-in>0 AND the decorator's own dropped ledger is non-zero — a loss gate that never dropped proves nothing, asserted explicitly. The lifecycle gate defensively clears the drop vars (decorator-absent baseline). **First loss-observing gate evidence (2026-07-29, 2%/seed 1, local ACE):** decorator dropped out=3 in=10 of forwarded out=183 in=496; `[net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114 acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0 uncached-nak=0 cache=1 nakset=0` — both recovery directions fired on a real connected route (ACE NAK → cached resend; client gap-walk park → NAK → ACE retransmit), all six checkpoints validated, graceful logout confirmed, ACE recorded the transport Disconnect, RESULT=PASS. The gate immediately paid for itself: it exposed that the Dispose logoff-confirmation wait processed inbound but never swept the transport, so a lost S2C confirmation could be gap-detected yet never NAKed — fixed by running the sweep in that third blocking pump (retail's `Client::UseTime @ 0x00411C40` pump runs until `LogOffServer`). Known tail caveat recorded in the gate header: a drop landing on the single-shot logoff request or transport Disconnect (~pct each) is unrecoverable by ACE's arrival-driven NAK design (§3 row 1) — rerun with another seed, never widen teardown tolerances. #261 filed for `LinkStatusSnapshot.PacketLossPercentage` (retail `CLinkStatusAverages` formula required; inventing a ratio forbidden). 747 Core.Net tests green (decorator determinism/direction/arming/structural-absence, the 5% seeded WorldSession lossy lifecycle with zero message loss + Headroom 256, `[net-tick]` field pins, Iteration-stamp pins). | -| N6 | pending | — | | +| N6 | complete | SHA recorded at closeout | ConnectResponse handshake retransmit + fragment-assembler eviction — the final implementation slice. **Retransmit:** while unconfirmed, the Connect character-list pump resends the IDENTICAL cleartext ConnectResponse (same sequence 1, same cookie, the one encoded datagram — no new outbound state) on retail's strict 0.333333333 s gate (`ClientNet::ProcessConnection @ 0x00545450` case `cs_ConnectionRequestAcked` at 0x0054547B, the constant at 0x00545481, the mask-0x41 strictly-greater test at 0x0054548C; `ClientNet::SendConnectAck @ 0x005440F0` re-stamps `lastSentHandshake_` at 0x00544102 and rebuilds the same cookie packet). Confirmation = the first checksum-valid post-negotiation packet without the ConnectRequest flag, retail's `cs_ConnectionRequestAcked → cs_Connected` edge (`ClientNet::ProcessPacket @ 0x00545100`: the 0x40000 exclusion at 0x0054514E, `SetConnectionState(..., 5)` at 0x00545160). The cadence rides the TransportClock (virtual-clock testable via `TransportClockSource`); the Connect deadline stays wall-clock. ACE-safety pinned against the N0 model: a duplicate while still `AuthConnectResponse` re-routes idempotently through NetworkManager's pre-route; after acceptance `CheckState` clause 2 drops it pre-CRC at zero keystream cost. Pre-N6 a lost ConnectResponse was a hang to the Connect deadline — routine on a real path, and the N5 decorator deliberately arms after this window, so nothing covered it. **Assembler eviction (AD-52):** partials evict 60 s after their last accepted fragment (re-stamp-on-update per retail `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), swept from `ReliableTransport.Sweep` on retail's 5 s flush cadence (`Indicator::FlushTimedOutEphInfo @ 0x0054A3D0`, gate at 0x0054A3DC; per-entry `fTimedOut @ 0x0054AE30`) — N4's RejectRetransmit abandonment had made an unrecoverable partial a reachable permanent state. A 64-entry completed-sequence ring drops late duplicates of already-completed messages instead of re-partialing them (the completed-then-duplicate leak). 60 s is a floor, never a tunable to shrink. **Fold-ins:** N5-review LOW-5 — `NetProbeTests` + `LossyTransportDecoratorTests` (the static-`NetDiagnostics`/`Console.SetOut` mutators) share one `DisableParallelization` xunit collection so they never run alongside classes constructing `WorldSession`. 757 Core.Net tests green (drop-first-ConnectResponse-retry-heals with exactly one retry and none after confirmation; clean handshake sends exactly one; server-responses-lost retries drop harmlessly at the model while the N2/N4 gap-walk → NAK → cached-retransmit path heals the handshake responses; model-level duplicate-after-acceptance CheckState pin; assembler TTL floor boundary/refresh-on-update/ring-drop/ring-bound; transport-level sweep eviction). Connected lifecycle gate PASS (capped six-checkpoint route + uncapped reconnect, graceful exits, zero failures). The N5-STRENGTHENED loss gate PASS on its first live run (2%/seed 1, local ACE): decorator `[net-loss] dropped out=3 in=10 forwarded out=181 in=496 armed=True`; `[net-final] resends=1 nak-in=1 nak-out=5 rej-in=0 acks-out=114 acks-in=116 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=8 reclaimed=0 uncached-nak=0 cache=1 nakset=0` — the per-direction recovery conjunction held (C2S: resends+nak-in > 0; S2C: nak-out > 0), all three keystream-health invariants zero, ledger converged at the single watermark cache entry. | diff --git a/src/AcDream.Core.Net/Packets/FragmentAssembler.cs b/src/AcDream.Core.Net/Packets/FragmentAssembler.cs index 2fedf43f..0041e65c 100644 --- a/src/AcDream.Core.Net/Packets/FragmentAssembler.cs +++ b/src/AcDream.Core.Net/Packets/FragmentAssembler.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace AcDream.Core.Net.Packets; /// @@ -16,19 +18,77 @@ namespace AcDream.Core.Net.Packets; /// the full message is released on the last fragment regardless of /// its index. /// Duplicate-fragment idempotence: receiving index N twice for the -/// same Sequence is harmless — the second copy is silently ignored. +/// same Sequence is harmless — the second copy is silently ignored. +/// A late duplicate of an ALREADY-COMPLETED message is dropped via +/// the recently-completed ring below instead of allocating a fresh +/// partial that could never complete. /// Single-fragment messages: Count=1 releases immediately on /// that one fragment with no buffering. -/// Orphaned partials: if fragments for a Sequence arrive but the -/// message never completes, they stay buffered until -/// is called or the assembler is disposed. -/// A future phase will add a TTL-based eviction. +/// Orphaned partials (Campaign N Slice N6): entries whose last +/// accepted fragment is older than +/// are dropped by , which +/// runs on a 5 s +/// cadence. N4's RejectRetransmit abandonment made an unrecoverable +/// partial a REACHABLE permanent state (the server pruned a +/// fragment-bearing packet from its cache and told us to stop +/// asking — that blob can never complete), so the pre-N6 "buffered +/// until DropAll" posture was a slow leak on a lossy link. /// /// +/// +/// +/// Retail oracle for the eviction shape: the client's ephemeral-blob info +/// table is pruned on a 5.0 s sweep gate +/// (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the x87 compare +/// against 5.0 at 0x0054A3DC), each entry timing out 5.0 s after its LAST +/// refresh (ArrivedEphInfo::fTimedOut @ 0x0054AE30; the timestamp is +/// re-stamped on every update, ArrivedEphInfo::UpdateNetBlobID +/// @ 0x0054AE00). Our partial entries mirror the re-stamp-on-update +/// rule; the 60 s TTL (vs retail's 5 s on its ordering-stamp table) and the +/// completed-sequence ring are acdream adaptations — divergence register +/// row AD-52. 60 s is a floor, not a tunable: a partial that is merely slow +/// (packet-level NAK recovery in flight) must never be evicted. +/// /// public sealed class FragmentAssembler { + /// + /// AD-52: age floor before an incomplete partial is dropped, measured + /// from its last ACCEPTED fragment. Any in-flight recovery (0.6 s NAK + /// cadence, ACE's 120 s S2C cache) resolves orders of magnitude faster; + /// only a server-abandoned partial (RejectRetransmit) can reach it. + /// Do not shrink. + /// + internal const double PartialTtlSeconds = 60.0; + + /// AD-52: how many recently-completed multi-fragment sequences + /// are remembered to drop late duplicates without re-partialing. + internal const int CompletedRingSize = 64; + + private static double DefaultNowSeconds() => + (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency; + private readonly Dictionary _inFlight = new(); + private readonly Func _nowSeconds; + + // Ring of the last CompletedRingSize completed multi-fragment sequences. + // _completedCount bounds the membership scan so the zero-initialized + // slots can never match a real sequence 0 (ACE's fragment sequences + // START at 0 — SessionConnectionData.cs:36). + private readonly uint[] _completedSequences = new uint[CompletedRingSize]; + private int _completedNext; + private int _completedCount; + + public FragmentAssembler() + : this(null) + { + } + + /// Test seam: injectable monotonic seconds source for the TTL + /// stamps and . Production uses + /// time. + internal FragmentAssembler(Func? nowSeconds) => + _nowSeconds = nowSeconds ?? DefaultNowSeconds; /// /// Number of logical messages currently partially-assembled (waiting on @@ -62,7 +122,12 @@ public sealed class FragmentAssembler // its own inbound assembler keys on Sequence for the same reason. if (!_inFlight.TryGetValue(h.Sequence, out var partial)) { - partial = new PartialMessage(h.Count, h.Queue); + // N6: a late duplicate of an already-completed message must not + // allocate a fresh partial that can never complete. + if (WasRecentlyCompleted(h.Sequence)) + return null; + + partial = new PartialMessage(h.Count, h.Queue, _nowSeconds()); _inFlight[h.Sequence] = partial; } @@ -71,6 +136,9 @@ public sealed class FragmentAssembler { partial.Fragments[h.Index] = fragment.Payload; partial.ReceivedCount++; + // Retail re-stamps on update (ArrivedEphInfo::UpdateNetBlobID + // @ 0x0054AE00): a slow-but-alive partial never ages out. + partial.LastFragmentSeconds = _nowSeconds(); } if (partial.ReceivedCount < partial.TotalFragments) @@ -91,6 +159,7 @@ public sealed class FragmentAssembler } _inFlight.Remove(h.Sequence); + RememberCompleted(h.Sequence); messageQueue = partial.Queue; return combined; } @@ -121,9 +190,16 @@ public sealed class FragmentAssembler header.Sequence, out PartialMessage? partial)) { + // N6: drop a late duplicate of an already-completed message + // instead of re-partialing it (the pre-N6 leak: the fresh + // partial could never complete and lived forever). + if (WasRecentlyCompleted(header.Sequence)) + return false; + partial = new PartialMessage( header.Count, - header.Queue); + header.Queue, + _nowSeconds()); _inFlight[header.Sequence] = partial; } else if (partial.TotalFragments != header.Count @@ -139,6 +215,7 @@ public sealed class FragmentAssembler partial.Fragments[header.Index] = fragment.Payload.ToArray(); partial.ReceivedCount++; + partial.LastFragmentSeconds = _nowSeconds(); } if (partial.ReceivedCount < partial.TotalFragments) @@ -164,14 +241,64 @@ public sealed class FragmentAssembler } _inFlight.Remove(header.Sequence); + RememberCompleted(header.Sequence); message = combined; messageQueue = partial.Queue; return true; } + /// + /// N6 age-based eviction: drop every partial whose last accepted + /// fragment is older than . Called by + /// on the retail 5 s + /// flush cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0). + /// Returns the number of partials evicted. + /// + internal int SweepExpired() + { + if (_inFlight.Count == 0) + return 0; + + double now = _nowSeconds(); + int evicted = 0; + foreach ((uint sequence, PartialMessage partial) in _inFlight) + { + // Strictly-older-than the floor: an entry exactly 60 s old + // survives (an eviction floor, never an eager cutoff). + if (now - partial.LastFragmentSeconds > PartialTtlSeconds) + { + // Dictionary.Remove during enumeration is safe on .NET + // Core 3.0+ and does not invalidate the enumerator. + _inFlight.Remove(sequence); + evicted++; + } + } + + return evicted; + } + /// Discard all in-flight partial messages. public void DropAll() => _inFlight.Clear(); + private bool WasRecentlyCompleted(uint sequence) + { + for (int i = 0; i < _completedCount; i++) + { + if (_completedSequences[i] == sequence) + return true; + } + + return false; + } + + private void RememberCompleted(uint sequence) + { + _completedSequences[_completedNext] = sequence; + _completedNext = (_completedNext + 1) % CompletedRingSize; + if (_completedCount < CompletedRingSize) + _completedCount++; + } + private sealed class PartialMessage { public readonly byte[]?[] Fragments; @@ -179,11 +306,17 @@ public sealed class FragmentAssembler public readonly ushort Queue; public int ReceivedCount; - public PartialMessage(int count, ushort queue) + /// Seconds stamp of the last ACCEPTED fragment (creation + /// stamp until one lands) — the TTL clock for + /// . + public double LastFragmentSeconds; + + public PartialMessage(int count, ushort queue, double nowSeconds) { TotalFragments = count; Fragments = new byte[count][]; Queue = queue; + LastFragmentSeconds = nowSeconds; } } } diff --git a/src/AcDream.Core.Net/Transport/ReliableTransport.cs b/src/AcDream.Core.Net/Transport/ReliableTransport.cs index 16b64f09..6fff835f 100644 --- a/src/AcDream.Core.Net/Transport/ReliableTransport.cs +++ b/src/AcDream.Core.Net/Transport/ReliableTransport.cs @@ -1,5 +1,6 @@ using System.Buffers; using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Transport; @@ -40,6 +41,19 @@ internal sealed class ReliableTransport : IDisposable public TransportStats Stats { get; } + /// + /// N6: retail's ephemeral-info flush cadence + /// (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the x87 compare + /// against 5.0 at 0x0054A3DC) — how often the sweep asks the fragment + /// assembler to evict aged partials. The per-entry TTL itself lives in + /// (AD-52). + /// + public const double AssemblerSweepSeconds = 5.0; + + private readonly FragmentAssembler? _assembler; + private readonly long _assemblerSweepTicks; + private long _assemblerSweepTimestamp; + public ReliableTransport( IsaacRandom outboundIsaac, IsaacRandom inboundIsaac, @@ -47,10 +61,16 @@ internal sealed class ReliableTransport : IDisposable ushort sessionIteration, DatagramSendDelegate send, TransportClock? clock = null, - ArrayPool? pool = null) + ArrayPool? pool = null, + FragmentAssembler? assembler = null) { Clock = clock ?? new TransportClock(); Stats = new TransportStats(); + _assembler = assembler; + // Same defensive rounding as the scheduler gates (N4 review F1). + _assemblerSweepTicks = + (long)Math.Round(AssemblerSweepSeconds * Clock.Frequency); + _assemblerSweepTimestamp = Clock.GetTimestamp(); Outbound = new OutboundFlowQueue( outboundIsaac, sessionClientId, @@ -91,8 +111,19 @@ internal sealed class ReliableTransport : IDisposable public void Sweep() { Clock.Update(); - Scheduler.Sweep(Clock.GetTimestamp()); + long now = Clock.GetTimestamp(); + Scheduler.Sweep(now); Outbound.TransmitPendingResends(); + + // N6: age out abandoned fragment partials on retail's 5 s flush + // cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0 — re-stamp + // the flush clock, then walk the table dropping timed-out entries). + if (_assembler is not null + && now - _assemblerSweepTimestamp >= _assemblerSweepTicks) + { + _assemblerSweepTimestamp = now; + _assembler.SweepExpired(); + } } /// Returns every rented cache buffer to the pool. diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 13c836e6..0a6f6bb4 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -67,13 +67,13 @@ internal sealed class NetClientWorldSessionTransport(IPEndPoint remote) /// /// /// -/// Still deferred: unsolicited-disconnect recovery and the optional -/// N6 handshake hardening (ConnectResponse 0.333 s retransmit). The full +/// Still deferred: unsolicited-disconnect recovery. The full /// Campaign N reliable transport is live in both directions: outbound /// sent-packet cache + resend on server NAK (N1), inbound sequence-aligned /// ISAAC + NAK set (N2), the retail 2.0 s cumulative-ack sweep (N3), client -/// NAK emission + RejectRetransmit reclaim (N4), and the N5 loss -/// observability + deterministic loss injection seam. +/// NAK emission + RejectRetransmit reclaim (N4), the N5 loss observability +/// + deterministic loss injection seam, and the N6 handshake hardening +/// (ConnectResponse 0.333 s retransmit + fragment-assembler eviction). /// /// public sealed class WorldSession : IDisposable @@ -680,6 +680,31 @@ public sealed class WorldSession : IDisposable private ushort _sessionIteration; private bool _transportNegotiated; + /// + /// N6: retail's ConnectResponse resend cadence — the x87 compare against + /// 0.333333333 in ClientNet::ProcessConnection @ 0x00545450 + /// (case cs_ConnectionRequestAcked at 0x0054547B; the constant + /// load at 0x00545481). The mask-0x41 status test at 0x0054548C bails on + /// less-than OR equal, so the gate opens only STRICTLY past the + /// boundary — the same strict shape as the N4 NAK gate. + /// + internal const double ConnectResponseRetrySeconds = 0.333333333; + + /// + /// N6: true once ANY checksum-valid post-negotiation server packet has + /// been decoded — the port of retail's connection confirmation: + /// ClientNet::ProcessPacket @ 0x00545100 promotes + /// cs_ConnectionRequestAcked → cs_Connected (the + /// SetConnectionState(..., 5) vtable call at 0x00545160) on the + /// first successfully processed packet whose header lacks the + /// ConnectRequest flag (the 0x40000 test at 0x0054514E), and the resend + /// case never fires again. While false, the Connect pump resends the + /// IDENTICAL cleartext ConnectResponse (same sequence 1, same cookie — + /// no new outbound state) every + /// . + /// + private bool _handshakeConfirmed; + /// /// Campaign N Slices N1+N2: the reliable transport — both ISAAC /// keystreams, packet/fragment sequences, sent-packet cache, resend on @@ -930,7 +955,10 @@ public sealed class WorldSession : IDisposable ? new TransportClock( clockSource.GetTimestamp, clockSource.Frequency) - : null); + : null, + // N6: the sweep ages out abandoned fragment partials (5 s + // cadence, 60 s TTL — FragmentAssembler doc + AD-52). + assembler: _assembler); _transportNegotiated = true; // Publish only after the receiver identity and crypto state are fully @@ -942,16 +970,44 @@ public sealed class WorldSession : IDisposable byte[] crBody = new byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie); var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 }; + byte[] connectResponseDatagram = PacketCodec.Encode(crHeader, crBody, null); Thread.Sleep(200); - _net.Send(_connectEndpoint, PacketCodec.Encode(crHeader, crBody, null)); + _net.Send(_connectEndpoint, connectResponseDatagram); + + // N6: arm the handshake resend clock. Retail stamps + // lastSentHandshake_ inside every ClientNet::SendConnectAck + // (@ 0x005440F0, the store at 0x00544102) and rebuilds the same + // ConnectResponse from the stored cookie each time; we keep the one + // encoded datagram and resend it verbatim — identical cleartext, + // sequence 1, no new state consumed. The cadence rides the + // transport clock so the conformance suite can drive it on virtual + // time; the Connect deadline stays wall-clock. + TransportClock handshakeClock = _transport.Clock; + long handshakeRetryTicks = (long)Math.Round( + ConnectResponseRetrySeconds * handshakeClock.Frequency); + long handshakeSentTimestamp = handshakeClock.GetTimestamp(); Transition(State.InCharacterSelect); // Step 4: drain until CharacterList arrives. The transport sweep // runs inside this blocking pump too (campaign landmine #8): the - // first server NAK can precede the first Tick(). + // first server NAK can precede the first Tick(). This pump is also + // retail's cs_ConnectionRequestAcked resend window + // (ClientNet::ProcessConnection @ 0x00545450 case 0 at 0x0054547B): + // until the first decoded server packet confirms the connection, a + // lost ConnectResponse is re-sent every 0.333 s — without it, one + // dropped handshake datagram is a hang to the Connect deadline + // (the N5 loss decorator deliberately arms AFTER this window). while (DateTime.UtcNow < deadline && Characters is null) { + if (!_handshakeConfirmed + && handshakeClock.GetTimestamp() - handshakeSentTimestamp + > handshakeRetryTicks) + { + _net.Send(_connectEndpoint, connectResponseDatagram); + handshakeSentTimestamp = handshakeClock.GetTimestamp(); + } + PumpOnce(); SweepTransport(); } @@ -1514,6 +1570,19 @@ public sealed class WorldSession : IDisposable // acceptance, before any heavy render-thread message handling. Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp()); + // N6: the first checksum-valid post-negotiation packet confirms the + // server accepted our ConnectResponse and stops the handshake + // resend — retail's cs_ConnectionRequestAcked → cs_Connected edge + // (ClientNet::ProcessPacket @ 0x00545100: the 0x40000 ConnectRequest + // exclusion at 0x0054514E, SetConnectionState(..., 5) at + // 0x00545160). Frame-thread only, like every reader. + if (!_handshakeConfirmed + && _transportNegotiated + && !serverHeader.HasFlag(PacketHeaderFlags.ConnectRequest)) + { + _handshakeConfirmed = true; + } + // N1: consume the transport control surfaces. Acknowledging the // OTHER direction is not done here: N3 deleted the Phase 4.9 // per-packet reflex ack — retail never acks per packet diff --git a/tests/AcDream.Core.Net.Tests/NetProbeTests.cs b/tests/AcDream.Core.Net.Tests/NetProbeTests.cs index ee8f0902..fa80ca24 100644 --- a/tests/AcDream.Core.Net.Tests/NetProbeTests.cs +++ b/tests/AcDream.Core.Net.Tests/NetProbeTests.cs @@ -13,6 +13,7 @@ namespace AcDream.Core.Net.Tests; /// AllocatesNothingOnceThePoolWarms) — counters increment /// unconditionally; string work is probe-gated. /// +[Collection(NetProcessStaticsCollection.Name)] public sealed class NetProbeTests { [Fact] diff --git a/tests/AcDream.Core.Net.Tests/NetProcessStaticsCollection.cs b/tests/AcDream.Core.Net.Tests/NetProcessStaticsCollection.cs new file mode 100644 index 00000000..222c3faa --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/NetProcessStaticsCollection.cs @@ -0,0 +1,25 @@ +namespace AcDream.Core.Net.Tests; + +/// +/// Campaign N Slice N6 fold-in (N5 review LOW-5): test classes that mutate +/// process-global state — the NetDiagnostics typed toggles +/// (ProbeNet, NetDropPercent, ...) or Console.SetOut — +/// must never run in parallel with any test constructing a +/// WorldSession, whose production paths read those statics (the +/// public constructor's LossyTransportDecorator.WrapIfConfigured +/// seam, the probe-gated [net-tick]/[net-final] console +/// emission in Tick/Dispose). +/// +/// +/// DisableParallelization = true makes xunit run this collection's +/// classes strictly serially, AFTER every parallel collection has finished +/// — never alongside anything. Apply +/// [Collection(NetProcessStaticsCollection.Name)] to any new test +/// class that touches those statics. +/// +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class NetProcessStaticsCollection +{ + public const string Name = "net process statics"; +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs b/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs index dc55cd87..ceb8e894 100644 --- a/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs +++ b/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs @@ -201,4 +201,126 @@ public class FragmentAssemblerTests Assert.Equal(new byte[] { 1, 2 }, message.ToArray()); Assert.Equal(0, assembler.PartialCount); } + + // ===================================================================== + // Campaign N Slice N6 — age-based eviction + the completed ring. + // Retail shape: Indicator::FlushTimedOutEphInfo @ 0x0054A3D0 (5 s flush + // gate) over entries re-stamped on every update (ArrivedEphInfo:: + // UpdateNetBlobID @ 0x0054AE00); the 60 s TTL and the 64-entry + // completed-sequence ring are the AD-52 adaptations. + // ===================================================================== + + private static BorrowedMessageFragment MakeBorrowed( + uint sequence, ushort count, ushort index, byte[] payload, ushort queue = 7) + => new(MakeFrag(sequence, count, index, payload, queue).Header, payload); + + [Fact] + public void SweepExpired_EvictsAgedPartial_KeepsFresh() + { + double now = 0; + var assembler = new FragmentAssembler(() => now); + + Assert.False(assembler.TryIngest(MakeBorrowed(1, 2, 0, [0xA1]), out _, out _)); + now = 30; + Assert.False(assembler.TryIngest(MakeBorrowed(2, 3, 0, [0xB1]), out _, out _)); + Assert.Equal(2, assembler.PartialCount); + + // At 60.0 exactly the first partial is AT the floor, not past it — + // an eviction floor, never an eager cutoff. + now = 60; + Assert.Equal(0, assembler.SweepExpired()); + Assert.Equal(2, assembler.PartialCount); + + // Past the floor: the aged partial goes, the fresh one stays. + now = 61; + Assert.Equal(1, assembler.SweepExpired()); + Assert.Equal(1, assembler.PartialCount); + + // The surviving partial still completes normally. + Assert.False(assembler.TryIngest(MakeBorrowed(2, 3, 1, [0xB2]), out _, out _)); + Assert.True(assembler.TryIngest( + MakeBorrowed(2, 3, 2, [0xB3]), + out ReadOnlyMemory message, + out _)); + Assert.Equal(new byte[] { 0xB1, 0xB2, 0xB3 }, message.ToArray()); + Assert.Equal(0, assembler.PartialCount); + } + + [Fact] + public void SweepExpired_SlowButAlivePartial_RefreshesOnEachNewFragment() + { + double now = 0; + var assembler = new FragmentAssembler(() => now); + + Assert.False(assembler.TryIngest(MakeBorrowed(5, 3, 0, [1]), out _, out _)); + now = 50; + Assert.False(assembler.TryIngest(MakeBorrowed(5, 3, 1, [2]), out _, out _)); + + // 61 s after creation but only 11 s after the last ACCEPTED + // fragment: the re-stamp rule (retail ArrivedEphInfo:: + // UpdateNetBlobID @ 0x0054AE00) keeps a slow-but-alive partial. + now = 61; + Assert.Equal(0, assembler.SweepExpired()); + Assert.Equal(1, assembler.PartialCount); + + // A DUPLICATE of an already-held index adds nothing and must not + // refresh the stamp: 61 s after the last new fragment, it goes. + now = 100; + Assert.False(assembler.TryIngest(MakeBorrowed(5, 3, 1, [2]), out _, out _)); + now = 111.5; + Assert.Equal(1, assembler.SweepExpired()); + Assert.Equal(0, assembler.PartialCount); + } + + [Fact] + public void TryIngest_LateDuplicateOfCompletedMessage_DropsWithoutRepartialing() + { + var assembler = new FragmentAssembler(); + + Assert.False(assembler.TryIngest(MakeBorrowed(9, 2, 0, [1]), out _, out _)); + Assert.True(assembler.TryIngest(MakeBorrowed(9, 2, 1, [2]), out _, out _)); + Assert.Equal(0, assembler.PartialCount); + + // The pre-N6 leak: this late duplicate allocated a fresh partial + // that could never complete. Now it drops via the completed ring. + Assert.False(assembler.TryIngest(MakeBorrowed(9, 2, 0, [1]), out _, out _)); + Assert.Equal(0, assembler.PartialCount); + } + + [Fact] + public void Ingest_LateDuplicateOfCompletedMessage_DropsWithoutRepartialing() + { + var assembler = new FragmentAssembler(); + + Assert.Null(assembler.Ingest(MakeFrag(9, 2, 0, [1]), out _)); + Assert.NotNull(assembler.Ingest(MakeFrag(9, 2, 1, [2]), out _)); + Assert.Equal(0, assembler.PartialCount); + + Assert.Null(assembler.Ingest(MakeFrag(9, 2, 0, [1]), out _)); + Assert.Equal(0, assembler.PartialCount); + } + + [Fact] + public void CompletedRing_IsBounded_OldestSequenceIsForgotten() + { + var assembler = new FragmentAssembler(); + + // Complete ring-size + 1 multi-fragment messages; sequence 0 is a + // legitimate value (ACE's fragment sequences start at 0). + for (uint seq = 0; seq <= 64; seq++) + { + Assert.False(assembler.TryIngest(MakeBorrowed(seq, 2, 0, [1]), out _, out _)); + Assert.True(assembler.TryIngest(MakeBorrowed(seq, 2, 1, [2]), out _, out _)); + } + + // Sequence 0 was pushed out of the 64-entry ring: its late + // duplicate re-partials (the documented bound — memory stays + // bounded and the TTL sweep reclaims the stragglers). + Assert.False(assembler.TryIngest(MakeBorrowed(0, 2, 0, [1]), out _, out _)); + Assert.Equal(1, assembler.PartialCount); + + // The newest completion is still remembered. + Assert.False(assembler.TryIngest(MakeBorrowed(64, 2, 0, [1]), out _, out _)); + Assert.Equal(1, assembler.PartialCount); + } } diff --git a/tests/AcDream.Core.Net.Tests/Transport/ConnectResponseRetransmitTests.cs b/tests/AcDream.Core.Net.Tests/Transport/ConnectResponseRetransmitTests.cs new file mode 100644 index 00000000..d8b04054 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Transport/ConnectResponseRetransmitTests.cs @@ -0,0 +1,289 @@ +using System.Buffers.Binary; +using System.Net; +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; + +namespace AcDream.Core.Net.Tests.Transport; + +/// +/// Campaign N Slice N6 — the ConnectResponse handshake retransmit. +/// +/// +/// Retail: while a connection sits in cs_ConnectionRequestAcked, +/// ClientNet::ProcessConnection @ 0x00545450 (case 0 at 0x0054547B) +/// re-sends the ConnectResponse every 0.333333333 s — strictly-greater gate +/// on lastSentHandshake_ (mask-0x41 x87 test at 0x0054548C) — +/// through ClientNet::SendConnectAck @ 0x005440F0, which re-stamps +/// the clock (0x00544102) and rebuilds the same cookie packet. The resend +/// stops when the first successfully processed non-ConnectRequest packet +/// promotes the connection to cs_Connected +/// (ClientNet::ProcessPacket @ 0x00545100, the state set at +/// 0x00545160). +/// +/// +/// +/// Clocking: the retry cadence rides the TransportClock, so these tests +/// drive it on VIRTUAL time (TransportClockSource + +/// AutoAdvanceOnBlockingReceive); only the Connect deadline is +/// wall-clock. Before N6 the first test hung to that deadline — a lost +/// ConnectResponse was an unconditional Connect failure, and the N5 loss +/// decorator deliberately arms AFTER the handshake window, so nothing +/// covered it. +/// +/// +public sealed class ConnectResponseRetransmitTests +{ + /// THE N6 conformance test: the first ConnectResponse datagram + /// dies on the wire; the 0.333 s retry lands; the session completes. + /// Exactly one retry — confirmation (the first decoded server packet) + /// stops the cadence. + [Fact] + public void Connect_FirstConnectResponseDropped_RetryHealsHandshake() + { + var fake = new FakeAceTransport + { + AutoAdvanceOnBlockingReceive = TimeSpan.FromMilliseconds(200), + }; + int connectResponsesSent = 0; + fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) => + { + if (!IsConnectResponse(datagram)) + return false; + connectResponsesSent++; + return connectResponsesSent == 1; // only the FIRST one dies + }); + int accepted = 0; + fake.Model.ConnectResponseAccepted += () => accepted++; + + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + fake); + session.TransportClockSource = + (fake.Clock.GetTimestamp, fake.Clock.Frequency); + + session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); + + Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState); + Assert.NotNull(session.Characters); + // Original + exactly one retry, and none after confirmation (the + // CharacterList response follows the accepted retry immediately). + Assert.Equal(2, connectResponsesSent); + Assert.Equal(1, accepted); + // The retry arrived while ACE was still AuthConnectResponse — the + // NetworkManager pre-route accepted it; nothing was state-dropped + // and no keystream/CRC cost was paid anywhere. + Assert.Equal(0, fake.Model.StateDropCount); + Assert.Equal(0, fake.Model.CrcDropCount); + Assert.Equal(0, fake.Model.DuplicateDropCount); + Assert.Equal(256, fake.Model.Crypto.Headroom); + } + + /// A clean handshake sends exactly ONE ConnectResponse: with + /// the virtual clock frozen (no auto-advance) the strict 0.333 s gate + /// can never open, and confirmation lands on the first pump. + [Fact] + public void Connect_CleanHandshake_SendsExactlyOneConnectResponse() + { + var fake = new FakeAceTransport(); + int connectResponsesSent = 0; + fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) => + { + if (IsConnectResponse(datagram)) + connectResponsesSent++; + return false; // tap only + }); + + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + fake); + session.TransportClockSource = + (fake.Clock.GetTimestamp, fake.Clock.Frequency); + + session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); + + Assert.NotNull(session.Characters); + Assert.Equal(1, connectResponsesSent); + Assert.Equal(0, fake.Model.StateDropCount); + } + + /// + /// The duplicate-in-flight scenario: ACE accepted the ConnectResponse + /// but its S2C responses died, so the unconfirmed client keeps + /// retrying. Every duplicate lands harmlessly (ACE's + /// Session.CheckState clause 2 drops it pre-CRC at zero + /// keystream cost), and the dropped responses heal through the normal + /// N2/N4 gap-walk → NAK → cached-retransmit path once ACE's 2 s ack + /// reveals the gap. + /// + [Fact] + public void Connect_ServerResponsesLost_RetriesDropHarmlessly_SessionHeals() + { + var fake = new FakeAceTransport + { + AutoAdvanceOnBlockingReceive = TimeSpan.FromMilliseconds(200), + }; + // S2C transmit index 0 is the ConnectRequest; indices 1 and 2 are + // the TimeSync + CharacterList responses to the accepted + // ConnectResponse. Drop both responses. + fake.Link.DropAt(LinkDirection.ServerToClient, 1); + fake.Link.DropAt(LinkDirection.ServerToClient, 2); + int connectResponsesSent = 0; + fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) => + { + if (IsConnectResponse(datagram)) + connectResponsesSent++; + return false; // tap only + }); + int accepted = 0; + fake.Model.ConnectResponseAccepted += () => accepted++; + + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + fake); + session.TransportClockSource = + (fake.Clock.GetTimestamp, fake.Clock.Frequency); + + session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); + + Assert.NotNull(session.Characters); + // The first ConnectResponse was accepted; at least one retry went + // out while the client sat unconfirmed, and every one of them was + // state-dropped by the model without side effects. + Assert.Equal(1, accepted); + Assert.True(connectResponsesSent >= 2, + $"expected retries, saw {connectResponsesSent}"); + Assert.True(fake.Model.StateDropCount >= 1, + $"expected CheckState drops, saw {fake.Model.StateDropCount}"); + Assert.Equal(0, fake.Model.CrcDropCount); + Assert.Equal(0, fake.Model.DuplicateDropCount); + // The dropped TimeSync/CharacterList healed via the client NAK → + // ACE cached-retransmit path, with the parked-key discipline intact. + Assert.True(session.Transport!.Stats.NaksSent >= 1); + Assert.True(session.Transport.Stats.KeysParked >= 2); + Assert.Equal(0, session.Transport.Stats.ChecksumFailures); + Assert.Equal(256, fake.Model.Crypto.Headroom); + } + + /// Model-level pin of the ACE-safety claim: a duplicate + /// ConnectResponse AFTER acceptance is dropped by CheckState clause 2 + /// (Session.cs:98-99 / NetworkManager.cs:60-66) before CRC — harmless, + /// stateless, zero keystream cost. + [Fact] + public void AceModel_DuplicateConnectResponse_AfterAcceptance_DropsViaCheckState() + { + var clock = new VirtualClock(); + var model = new AceSessionModel( + clock, + FakeAceTransport.DefaultClientSeed, + FakeAceTransport.DefaultServerSeed, + FakeAceTransport.DefaultClientId, + FakeAceTransport.DefaultCookie); + int accepted = 0; + model.ConnectResponseAccepted += () => accepted++; + + byte[] cookieBody = new byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian( + cookieBody, FakeAceTransport.DefaultCookie); + byte[] connectResponse = PacketCodec.Encode( + new PacketHeader + { + Sequence = 1, + Flags = PacketHeaderFlags.ConnectResponse, + Id = 0, + }, + cookieBody, + null); + + // AuthenticationHandler moves the session to AuthConnectResponse + // when the ConnectRequest goes out. + model.SendConnectRequest(); + Assert.Equal(AceSessionState.AuthConnectResponse, model.State); + + model.Receive(connectResponse); + Assert.Equal(AceSessionState.AuthConnected, model.State); + Assert.Equal(1, accepted); + Assert.Equal(0, model.StateDropCount); + + // The duplicate: CheckState clause 2 drops it pre-CRC. + model.Receive(connectResponse); + Assert.Equal(AceSessionState.AuthConnected, model.State); + Assert.Equal(1, accepted); + Assert.Equal(1, model.StateDropCount); + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(0, model.DuplicateDropCount); + Assert.Equal(256, model.Crypto.Headroom); + } + + private static bool IsConnectResponse(byte[] datagram) => + datagram.Length >= PacketHeader.Size + && (BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(4)) + & (uint)PacketHeaderFlags.ConnectResponse) != 0; +} + +/// +/// Campaign N Slice N6 — the transport end of the fragment-assembler +/// eviction: runs +/// on retail's 5 s flush +/// cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0). +/// +public sealed class ReliableTransportAssemblerSweepTests +{ + [Fact] + public void Sweep_EvictsAgedPartial_KeepsFreshOne() + { + var clock = new VirtualClock(); + var assembler = new FragmentAssembler(() => clock.Seconds); + var transport = new ReliableTransport( + MakeIsaac(0x11AA22BBu), + MakeIsaac(0x33CC44DDu), + 0x1234, + 1, + _ => { }, + new TransportClock(clock.GetTimestamp, clock.Frequency), + assembler: assembler); + + // Park a partial at t=0. + IngestPartial(assembler, sequence: 10); + Assert.Equal(1, assembler.PartialCount); + + // Well under the TTL: sweeps run (5 s cadence) but evict nothing. + clock.Advance(TimeSpan.FromSeconds(30)); + transport.Sweep(); + Assert.Equal(1, assembler.PartialCount); + + // Park a second partial at t=58, then cross the first one's TTL. + clock.Advance(TimeSpan.FromSeconds(28)); + IngestPartial(assembler, sequence: 11); + clock.Advance(TimeSpan.FromSeconds(3.5)); // t = 61.5 + transport.Sweep(); + + Assert.Equal(1, assembler.PartialCount); // 10 evicted, 11 kept + transport.Dispose(); + } + + private static void IngestPartial(FragmentAssembler assembler, uint sequence) + { + var header = new MessageFragmentHeader + { + Sequence = sequence, + Id = 0x80000000u, + Count = 2, + Index = 0, + TotalSize = (ushort)(MessageFragmentHeader.Size + 1), + Queue = 7, + }; + byte[] payload = { 0x42 }; + Assert.False(assembler.TryIngest( + new BorrowedMessageFragment(header, payload), + out _, + out _)); + } + + private static IsaacRandom MakeIsaac(uint seed) + { + Span seedBytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); + return new IsaacRandom(seedBytes); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Transport/LossyTransportDecoratorTests.cs b/tests/AcDream.Core.Net.Tests/Transport/LossyTransportDecoratorTests.cs index 10b5390c..2cba13f4 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/LossyTransportDecoratorTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/LossyTransportDecoratorTests.cs @@ -14,6 +14,7 @@ namespace AcDream.Core.Net.Tests.Transport; /// bidirectional loss over the N0 ACE double with zero message loss and the /// 256-key crypto window intact. /// +[Collection(AcDream.Core.Net.Tests.NetProcessStaticsCollection.Name)] public sealed class LossyTransportDecoratorTests { // =====================================================================