diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 309da372..fed01aed 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -226,7 +226,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-123 | Item cooldowns use the retained toolkit's existing procedural `UiItemSlot` leaf rather than materializing retail's ten `m_elem_Icon_Cooldown_*` child elements. The group lookup, remaining-time formula, exact DAT sprites, step choice, and topmost ReadOrder-8 outcome are faithful. | `src/AcDream.App/UI/Layout/ItemCooldownUiController.cs`; `src/AcDream.App/UI/UiItemSlot.cs` | `UiItemSlot` already consumes/reproduces UIItem children procedurally under the IA-15 retained-toolkit architecture. Selecting one imported sprite at draw time gives every inventory/equipment/shortcut alias the same output without a parallel widget tree or per-cell timers. | A future feature that observes the individual cooldown child visibility/state rather than the rendered UIItem could see no child elements even though the cell looks and advances correctly. | `CEnchantmentRegistry::OnCooldown @ 0x005943C0`; `UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20`; common UIItem prototype `0x1000033E` | | AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` | -## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 39 active rows (TS-57 filed 2026-07-29 at Campaign N slice N1 — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -247,7 +247,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) | | TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | | TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 | -| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | +| TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | | TS-28 | **NARROWED 2026-07-15** — F751 teleports now resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login still sends LoginComplete directly from the PlayerCreate (0xF746) handler and does not enter the portal-space presentation. | `src/AcDream.Core.Net/WorldSession.cs` (PlayerCreate branch); `src/AcDream.App/Rendering/GameWindow.cs` (F751 `FireLoginComplete`) | The live-session bootstrap currently needs the acknowledgement to unlock the initial authoritative object/property stream; moving initial login behind the App presentation requires an explicit session→presentation readiness contract rather than withholding it inside Core.Net | Initial login can expose server updates earlier than retail and skips the wormhole presentation; recalls/portals now have retail ordering | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | | TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | @@ -271,6 +271,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | | TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` | | TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | +| TS-57 | No outbound `RejectRetransmit`: a server NAK for an id no longer in the sent-packet cache is dropped silently (counted in `TransportStats.UncachedNakIds`); retail answers `RejectRetransmit @ FlowQueue` so the server abandons the id immediately | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`OnRetransmitRequest`) | ACE parses `RejectRetransmit` and no-ops it (NetworkSession.cs — no handler), and the standalone unsequenced form would trip ACE's watermark hole (campaign doc §3 row 3: any cleartext non-ack packet with a live sequence advances the watermark and skips a real packet forever) | Against a server that DOES honor RejectRetransmit, an uncached NAKed id keeps being re-requested until that server's own NAK give-up logic fires — never against ACE, which forgets the id when its next cumulative ack passes it | `RecipientData::ProcessNaks @ 0x00547010`; ACE NetworkSession.cs:299-304 (server-side emit), no client-consume handler | | TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` | --- @@ -297,7 +298,7 @@ likelihood the guarding assumption breaks). Items below the line are phase-gated — they carry their trigger in their row and should land WITH that phase, not before. -1. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there. +1. **TS-27 — INBOUND retransmit handling** — the outbound sent-packet cache + resend landed with Campaign N Slice N1 (2026-07-29, class-doc gap list fixed same commit); the inbound sequence-aligned ISAAC + client NAK emission (N2/N4) remain the hard blocker for non-loopback play — one lost S2C packet still deafens the session permanently. 2. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. 3. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). 4. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. diff --git a/docs/plans/2026-07-29-network-transport-campaign.md b/docs/plans/2026-07-29-network-transport-campaign.md index 8f4b38f3..991a9a3f 100644 --- a/docs/plans/2026-07-29-network-transport-campaign.md +++ b/docs/plans/2026-07-29-network-transport-campaign.md @@ -251,8 +251,8 @@ verbatim in each implementer prompt. | Slice | Status | Commit | Notes | |---|---|---|---| -| N0 | complete | `7e9134b4` + the `test(net): N0 fix-up` commit | 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 | pending | — | | +| 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 | (this slice's commit; SHA recorded at N2 kickoff) | 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. | | N2 | pending | — | | | N3 | pending | — | | | N4 | pending | — | | diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs index dd7fdaea..9033dc8b 100644 --- a/src/AcDream.Core.Net/Packets/PacketCodec.cs +++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs @@ -561,7 +561,35 @@ public static class PacketCodec Span datagram, int bodyLength, int optionalLength, - IsaacRandom? outboundIsaac) + IsaacRandom? outboundIsaac) => + FinalizeInPlace( + header, + datagram, + bodyLength, + optionalLength, + outboundIsaac, + out _, + out _); + + /// + /// + /// plus the two values the sent-packet cache needs for a header-rebuilt + /// resend (Campaign N §2.1): is the + /// keystream word this encode consumed (0 for cleartext), and + /// is the checksum value BEFORE the + /// header hash is added — retail NetPacket::checksum_: + /// payloadHash for cleartext, isaacKey ^ payloadHash for + /// encrypted. A resend recomputes only the header hash and adds this + /// stored sealed value, reusing the original key (landmines #1/#2). + /// + internal static int FinalizeInPlace( + PacketHeader header, + Span datagram, + int bodyLength, + int optionalLength, + IsaacRandom? outboundIsaac, + out uint isaacKeyUsed, + out uint sealedChecksum) { if ((uint)bodyLength > ushort.MaxValue) { @@ -602,14 +630,16 @@ public static class PacketCodec } uint isaacKey = outboundIsaac.Next(); - header.Checksum = - headerHash + (isaacKey ^ payloadHash); + isaacKeyUsed = isaacKey; + sealedChecksum = isaacKey ^ payloadHash; } else { - header.Checksum = headerHash + payloadHash; + isaacKeyUsed = 0; + sealedChecksum = payloadHash; } + header.Checksum = headerHash + sealedChecksum; header.Pack(datagram); return datagramLength; } diff --git a/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs b/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs new file mode 100644 index 00000000..8f9a3281 --- /dev/null +++ b/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs @@ -0,0 +1,310 @@ +using System.Buffers; +using System.Buffers.Binary; +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Transport; + +/// Sends one finalized datagram to the wire. Span-shaped so the +/// send path stays allocation-free. +internal delegate void DatagramSendDelegate(ReadOnlySpan datagram); + +/// +/// The outbound half of retail's reliable transport +/// (RecipientData + ClientFlowQueue + SentPacketStore +/// under PacketController): owns the outbound ISAAC keystream, the +/// reliable packet sequence (highestIDSent_), the fragment sequence, +/// the sent-packet cache, the pending-resend id list, and the cumulative-ack +/// watermark (flushNum_). +/// +/// Ported rules (campaign doc §2.1): +/// +/// Every reliable packet is cached AFTER a successful send +/// (FlowQueue::TransmitNewPackets @ 0x00547A60, cache commit at +/// 0x00547C85SentPacketStore::AddSentPacket @ 0x0054AB00). +/// Server RequestRetransmit ids merge-insert wrap-safe sorted +/// with dedup (FlowQueue::EnqueueAcks @ 0x005488E0); ids[0] +/// doubles as an implicit cumulative ack +/// (RecipientData::ProcessNaks @ 0x00547010). +/// A resend re-emits the cached body with a REBUILT 20-byte header — +/// flags Retransmission|EncryptedChecksum (plus BlobFragments +/// when the body has fragments), Time = the CURRENT interval id, +/// Sequence/DataSize verbatim, checksum = fresh header hash + +/// stored sealed checksum (FlowQueue::TransmitAcks @ 0x005485B0 / +/// DequeueAck @ 0x005472F0). The original ISAAC key is reused via +/// the stored sealed checksum — never a new keystream word +/// (CryptoSystem::EncryptData @ 0x0065FF40, non-null key path; +/// campaign landmines #1/#2). +/// AckSequence folds wrap-safe max into the watermark; the +/// cache prunes STRICTLY older (SentPacketStore::Flush @ 0x0054ACD0). +/// No timer-based resend exists — resend only on explicit NAK (landmine #3). +/// Sequence allocation: highestIDSent_ starts 1, pre-increment, +/// wrap 0xFFFFFFFF → 1 (never 0). +/// +/// +/// +/// Single-threaded by design (the ISAAC keystream is order-sensitive): +/// every member runs on the session's frame thread, matching the existing +/// WorldSession send discipline. +/// +/// +internal sealed class OutboundFlowQueue : IDisposable +{ + private readonly IsaacRandom _outboundIsaac; + private readonly TransportClock _clock; + private readonly TransportStats _stats; + private readonly DatagramSendDelegate _send; + private readonly SentPacketStore _store; + private readonly ArrayPool _pool; + private readonly ushort _sessionClientId; + + /// Wrap-safe sorted pending NAKed ids awaiting the next sweep + /// (FlowQueue::EnqueueAcks @ 0x005488E0 merge-insert). + private readonly List _pendingResends = new(); + + /// Retail highestIDSent_ — the last reliable sequence + /// put on the wire. Starts 1 (the ConnectResponse holds sequence 1), so + /// the first reliable packet after the handshake is sequence 2. + public uint HighestIdSent { get; private set; } + + /// The fragment sequence the NEXT reliable message will use. + /// Starts 1, exactly like the pre-N1 WorldSession field. + public uint FragmentSequence { get; private set; } + + /// Retail flushNum_ — the wrap-safe cumulative-ack + /// watermark; the cache holds everything at or above it. + public uint AckWatermark { get; private set; } + + public int CacheDepth => _store.Count; + + public int PendingResendCount => _pendingResends.Count; + + public OutboundFlowQueue( + IsaacRandom outboundIsaac, + ushort sessionClientId, + TransportClock clock, + TransportStats stats, + DatagramSendDelegate send, + ArrayPool? pool = null, + uint highestIdSent = 1, + uint fragmentSequence = 1) + { + ArgumentNullException.ThrowIfNull(outboundIsaac); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(stats); + ArgumentNullException.ThrowIfNull(send); + + _outboundIsaac = outboundIsaac; + _sessionClientId = sessionClientId; + _clock = clock; + _stats = stats; + _send = send; + _pool = pool ?? ArrayPool.Shared; + _store = new SentPacketStore(_pool); + HighestIdSent = highestIdSent; + FragmentSequence = fragmentSequence; + } + + /// The sequence the next reliable packet will carry — + /// pre-increment with retail's 0xFFFFFFFF → 1 wrap (never 0). + public uint PeekNextPacketSequence => NextSequenceAfter(HighestIdSent); + + private static uint NextSequenceAfter(uint sequence) => + sequence == uint.MaxValue ? 1u : sequence + 1u; + + /// + /// Encode one game message as a single-fragment reliable packet, send + /// it, THEN cache it (retail commits to the sent-packet store only after + /// a successful send — FlowQueue::TransmitNewPackets @ 0x00547C85). + /// Wire shape is byte-identical to the pre-N1 WorldSession path: + /// flags BlobFragments|EncryptedChecksum, Time/Iteration + /// zero, session client id, one ISAAC word. + /// + public void SendGameMessage( + ReadOnlySpan gameMessageBody, + GameMessageGroup queue) + { + byte[] buffer = _pool.Rent( + PacketHeader.Size + + MessageFragmentHeader.Size + + gameMessageBody.Length); + try + { + int fragmentLength = GameMessageFragment.WriteSingleFragment( + buffer.AsSpan(PacketHeader.Size), + FragmentSequence, + queue, + gameMessageBody); + var header = new PacketHeader + { + Sequence = PeekNextPacketSequence, + Flags = PacketHeaderFlags.BlobFragments + | PacketHeaderFlags.EncryptedChecksum, + Id = _sessionClientId, + }; + int datagramLength = PacketCodec.FinalizeInPlace( + header, + buffer, + fragmentLength, + optionalLength: 0, + _outboundIsaac, + out uint isaacKeyUsed, + out uint sealedChecksum); + + // The encode succeeded: the keystream word is drawn, so the + // sequence pair is committed even if the UDP send below faults + // (retail requeues from the head instead; register TS-61 — + // effectively unreachable on a connectionless socket). + FragmentSequence++; + HighestIdSent = header.Sequence; + + _send(buffer.AsSpan(0, datagramLength)); + + _store.Add( + new SentPacketStore.CachedPacket( + header.Sequence, + buffer, + fragmentLength, + sealedChecksum, + isaacKeyUsed, + hasFragments: true), + optionalLength: 0); + } + catch + { + _pool.Return(buffer); + throw; + } + } + + /// + /// Consume an inbound cumulative ack: wrap-safe max into the watermark + /// (RecipientData::ProcessNaks @ 0x00547010 fold shape). Pruning + /// happens on the next sweep. + /// + public void OnAckSequence(uint ackSequence) + { + _stats.AcksConsumed++; + AckWatermark = SequenceMath.Max(AckWatermark, ackSequence); + } + + /// + /// Consume an inbound RequestRetransmit id list (raw + /// little-endian u32 ids, count entries — the borrowed optional header's + /// RetransmitRequestBytes/RetransmitRequestCount pair). + /// Cached ids merge-insert into the pending-resend list + /// (FlowQueue::EnqueueAcks @ 0x005488E0); ids no longer cached + /// are dropped silently and counted — retail answers + /// RejectRetransmit (divergence register TS-57). ids[0] + /// also folds into the ack watermark as retail's implicit cumulative ack + /// (RecipientData::ProcessNaks @ 0x00547010). + /// + public void OnRetransmitRequest(ReadOnlySpan idBytes, int count) + { + if (count <= 0 || idBytes.Length < count * 4) + return; + + _stats.NakRequestsReceived++; + for (int i = 0; i < count; i++) + { + uint id = BinaryPrimitives.ReadUInt32LittleEndian( + idBytes.Slice(i * 4)); + if (i == 0) + OnAckSequence(id); + + if (_store.Contains(id)) + MergeInsertPending(id); + else + _stats.UncachedNakIds++; + } + } + + /// Wrap-safe sorted insert with dedup + /// (FlowQueue::EnqueueAcks @ 0x005488E0). + private void MergeInsertPending(uint id) + { + int index = 0; + while (index < _pendingResends.Count + && SequenceMath.IsNewer(id, _pendingResends[index])) + { + index++; + } + + if (index < _pendingResends.Count && _pendingResends[index] == id) + return; + + _pendingResends.Insert(index, id); + } + + /// + /// The per-sweep resend pass (FlowQueue::TransmitAcks @ 0x005485B0 + /// / DequeueAck @ 0x005472F0): serve every pending NAKed id in + /// ascending wrap-safe order with a rebuilt header, then prune the cache + /// strictly below the watermark. Resend happens ONLY here, only for + /// explicitly NAKed ids (landmine #3 — never resend unrequested). + /// + public void TransmitPendingResends() + { + if (_pendingResends.Count > 0) + { + for (int i = 0; i < _pendingResends.Count; i++) + { + // A pending id can leave the cache between NAK arrival and + // this sweep only if a newer ack already covered it — the + // server has it; serving nothing is correct. + if (!_store.TryGet( + _pendingResends[i], + out SentPacketStore.CachedPacket cached)) + { + continue; + } + + Resend(in cached); + } + + _pendingResends.Clear(); + } + + _store.FlushOlderThan(AckWatermark); + } + + private void Resend(in SentPacketStore.CachedPacket cached) + { + PacketHeader header = BuildResendHeader(in cached, _clock.IntervalId); + header.Pack(cached.Buffer); + _send(cached.Buffer.AsSpan( + 0, + PacketHeader.Size + cached.BodyLength)); + _stats.ResendsSent++; + } + + /// + /// Rebuild the 20-byte resend header (campaign §2.1 / landmine #1 — + /// resends are NOT byte-identical): flags become exactly + /// Retransmission|EncryptedChecksum (=3; |BlobFragments + /// =7 with fragments), Time advances to the current interval id + /// (retail behavior; ACE ignores inbound Time), Sequence/ + /// Id/Iteration/DataSize stay verbatim, and the + /// checksum is the FRESH header hash plus the stored sealed checksum — + /// the original ISAAC key rides along inside the sealed value, so no + /// new keystream word is ever drawn (landmine #2). + /// + internal static PacketHeader BuildResendHeader( + in SentPacketStore.CachedPacket cached, + ushort intervalId) + { + PacketHeader header = PacketHeader.Unpack(cached.Buffer); + PacketHeaderFlags flags = PacketHeaderFlags.Retransmission + | PacketHeaderFlags.EncryptedChecksum; + if (cached.HasFragments) + flags |= PacketHeaderFlags.BlobFragments; + header.Flags = flags; + header.Time = intervalId; + header.Checksum = + header.CalculateHeaderHash32() + cached.SealedChecksum; + return header; + } + + public void Dispose() => _store.Dispose(); +} diff --git a/src/AcDream.Core.Net/Transport/ReliableTransport.cs b/src/AcDream.Core.Net/Transport/ReliableTransport.cs new file mode 100644 index 00000000..268f9a3d --- /dev/null +++ b/src/AcDream.Core.Net/Transport/ReliableTransport.cs @@ -0,0 +1,71 @@ +using System.Buffers; +using AcDream.Core.Net.Cryptography; + +namespace AcDream.Core.Net.Transport; + +/// +/// Composition root for the session's reliable transport (campaign doc §4): +/// one , the outbound flow queue (N1), and the +/// unconditional counters. The inbound sequence tracker joins in N2 and the +/// AckNakScheduler in N3/N4 — N1 deliberately leaves ack behavior in +/// WorldSession untouched. +/// +/// +/// is the once-per-frame pump slice retail runs from +/// Client::UseTime @ 0x00411C40 → +/// PacketController::UseTime @ 0x005410D0: advance the interval +/// clock, serve pending retransmits, prune the acked cache. The session +/// calls it at the end of Tick() AND inside the blocking handshake +/// pump loops (landmine #8 — the EnterWorld flood precedes the first Tick), +/// gated on transport negotiation (ACE's Session.CheckState discards +/// early control traffic). +/// +/// +internal sealed class ReliableTransport : IDisposable +{ + public TransportClock Clock { get; } + + public OutboundFlowQueue Outbound { get; } + + public TransportStats Stats { get; } + + public ReliableTransport( + IsaacRandom outboundIsaac, + ushort sessionClientId, + DatagramSendDelegate send, + TransportClock? clock = null, + ArrayPool? pool = null) + { + Clock = clock ?? new TransportClock(); + Stats = new TransportStats(); + Outbound = new OutboundFlowQueue( + outboundIsaac, + sessionClientId, + Clock, + Stats, + send, + pool); + Stats.CacheDepthSource = () => Outbound.CacheDepth; + } + + /// Last reliable sequence on the wire — the value unsequenced + /// control packets (the reflex ack) borrow without incrementing. + public uint HighestIdSent => Outbound.HighestIdSent; + + /// + /// One transport pump: interval clock forward, pending NAKed resends + /// out, acked cache entries pruned. Pump order per retail + /// FlowQueue::Empty @ 0x00548A20 (NAK consumption already + /// happened at receive time; retransmits precede 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). + /// + public void Sweep() + { + Clock.Update(); + Outbound.TransmitPendingResends(); + } + + /// Returns every rented cache buffer to the pool. + public void Dispose() => Outbound.Dispose(); +} diff --git a/src/AcDream.Core.Net/Transport/SentPacketStore.cs b/src/AcDream.Core.Net/Transport/SentPacketStore.cs new file mode 100644 index 00000000..409b94c6 --- /dev/null +++ b/src/AcDream.Core.Net/Transport/SentPacketStore.cs @@ -0,0 +1,131 @@ +using System.Buffers; + +namespace AcDream.Core.Net.Transport; + +/// +/// FIFO cache of sent reliable datagrams awaiting the server's cumulative +/// ack, ported from retail's SentPacketStore: +/// AddSentPacket @ 0x0054AB00 appends to the intrusive FIFO list +/// (m_sentPacketList, ctor/list plumbing @ 0x0054A8A0/0x0054A8D0); +/// Flush @ 0x0054ACD0 pops from the head while the entry's sequence +/// is STRICTLY older than the watermark, wrap-safe (the inline +/// lhs_newer arithmetic breaks the walk at seqNum_ == arg2 or +/// any not-older entry). The cache is unbounded like retail's — ACE acks +/// every 2 s, so steady state is tens of entries; depth is surfaced as the +/// watchdog, never silently capped (campaign doc §4). +/// +/// +/// Each entry keeps the COMPLETE wire buffer (20-byte header at +/// [0..20), body at [20..20+BodyLength)) in an +/// ArrayPool-rented array, plus the two values a rebuilt resend header +/// needs: the sealed checksum (retail NetPacket::checksum_ — the +/// pre-header-hash value) and the ISAAC key that sealed it (never redrawn — +/// campaign landmine #2). +/// +/// +/// +/// Reliable packets never carry optional headers under the campaign's +/// standalone-control design (§4), which makes retail's +/// NetPacket::RemoveDisposableOptionalHeaders @ 0x00549510 strip a +/// provable no-op — asserts it. +/// +/// +internal sealed class SentPacketStore : IDisposable +{ + /// One cached sent packet (campaign doc §4 cache entry). + internal readonly struct CachedPacket( + uint sequence, + byte[] buffer, + int bodyLength, + uint sealedChecksum, + uint isaacKey, + bool hasFragments) + { + /// Packet sequence (header verbatim on resend). + public uint Sequence { get; } = sequence; + + /// Rented wire buffer: header [0..20), body [20..20+BodyLength). + public byte[] Buffer { get; } = buffer; + + /// Body byte count (header DataSize verbatim on resend). + public int BodyLength { get; } = bodyLength; + + /// Checksum value BEFORE the header hash is added: + /// isaacKey ^ payloadHash for encrypted packets (retail + /// NetPacket::checksum_). + public uint SealedChecksum { get; } = sealedChecksum; + + /// The outbound ISAAC word that sealed this packet. Kept for + /// audit/diagnostics — a resend reuses + /// and NEVER draws a new word (CryptoSystem::EncryptData @ + /// 0x0065FF40 non-null key path). + public uint IsaacKey { get; } = isaacKey; + + /// True when the body carries fragments — the rebuilt + /// resend header ORs BlobFragments back in. + public bool HasFragments { get; } = hasFragments; + } + + private readonly ArrayPool _pool; + private readonly Queue _fifo = new(); + private readonly Dictionary _bySequence = new(); + + public SentPacketStore(ArrayPool? pool = null) => + _pool = pool ?? ArrayPool.Shared; + + public int Count => _fifo.Count; + + /// + /// Cache one successfully sent reliable packet + /// (SentPacketStore::AddSentPacket @ 0x0054AB00; the caller + /// commits AFTER the send succeeds, FlowQueue::TransmitNewPackets @ + /// 0x00547C85). Takes ownership of packet.Buffer. + /// + /// + /// Asserted zero: our reliable packets never carry optional headers, so + /// retail's disposable-optional-header strip is a no-op (campaign §2.1). + /// + public void Add(in CachedPacket packet, int optionalLength) + { + if (optionalLength != 0) + { + throw new InvalidOperationException( + "reliable packets must not carry optional headers — " + + "NetPacket::RemoveDisposableOptionalHeaders @ 0x00549510 " + + "is pinned as a no-op (campaign §2.1)"); + } + + _bySequence.Add(packet.Sequence, packet); + _fifo.Enqueue(packet); + } + + public bool Contains(uint sequence) => _bySequence.ContainsKey(sequence); + + public bool TryGet(uint sequence, out CachedPacket packet) => + _bySequence.TryGetValue(sequence, out packet); + + /// + /// Pop from the FIFO head while the head is STRICTLY older than + /// , wrap-safe — the watermark entry itself + /// survives (SentPacketStore::Flush @ 0x0054ACD0). Returned + /// buffers go back to the pool. + /// + public void FlushOlderThan(uint watermark) + { + while (_fifo.TryPeek(out CachedPacket head) + && SequenceMath.IsNewer(watermark, head.Sequence)) + { + _fifo.Dequeue(); + _bySequence.Remove(head.Sequence); + _pool.Return(head.Buffer); + } + } + + /// Return every rented buffer to the pool. + public void Dispose() + { + while (_fifo.TryDequeue(out CachedPacket entry)) + _pool.Return(entry.Buffer); + _bySequence.Clear(); + } +} diff --git a/src/AcDream.Core.Net/Transport/SequenceMath.cs b/src/AcDream.Core.Net/Transport/SequenceMath.cs new file mode 100644 index 00000000..49003c4c --- /dev/null +++ b/src/AcDream.Core.Net/Transport/SequenceMath.cs @@ -0,0 +1,30 @@ +namespace AcDream.Core.Net.Transport; + +/// +/// Wrap-safe 32-bit transport sequence comparisons, ported from retail +/// TimeStampUtils::lhs_newer(uint32_t, uint32_t) @ 0x00543890 +/// (named-retail pseudo-C :334086): compute the unsigned distance, flip the +/// verdict when it exceeds 0x7FFFFFFF. That is exactly the sign of the +/// two's-complement difference, so reduces to one +/// signed comparison. +/// +/// +/// Boundary note: at a distance of exactly 0x80000000 retail's flip +/// arithmetic answers asymmetrically (the numerically smaller value reads as +/// newer); the signed-difference form answers "not newer" for both +/// directions. The half-window boundary is unreachable for transport +/// sequences (ACE terminates a session at a gap of 257 — +/// AbnormalSequenceReceived), so the campaign pins the simpler form +/// (campaign doc §4). +/// +/// +internal static class SequenceMath +{ + /// True when is strictly newer than + /// in wrap-safe sequence order. + public static bool IsNewer(uint a, uint b) => unchecked((int)(a - b)) > 0; + + /// The wrap-safe newer of two sequence values (either one when + /// they are equal). + public static uint Max(uint a, uint b) => IsNewer(a, b) ? a : b; +} diff --git a/src/AcDream.Core.Net/Transport/TransportClock.cs b/src/AcDream.Core.Net/Transport/TransportClock.cs new file mode 100644 index 00000000..20817234 --- /dev/null +++ b/src/AcDream.Core.Net/Transport/TransportClock.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; + +namespace AcDream.Core.Net.Transport; + +/// +/// The transport's monotonic time authority: an injectable timestamp source +/// (production default ) driving retail's +/// 0.5-second interval counter. is the value retail +/// writes into ProtoHeader::interval_ — our +/// — on rebuilt resend headers. +/// +/// +/// Retail oracle: ClientFlowQueue::IncrementLocalInterval @ 0x00547F10 +/// (named-retail pseudo-C :338389) — the tail is +/// CurLocalInterval_.intervalID_ += elapsedIntervals, advanced by the +/// caller once per elapsed 0.5-s slice. Only the interval counter is in +/// N1 scope: the same function's every-6-intervals TimeSync/EchoRequest +/// (~3 s) and every-0xDC-intervals CICMD keepalive are campaign §5 +/// deferrals (TS-58) — standalone-unsafe against ACE's watermark hole. +/// +/// +/// +/// One clock owns every transport gate (campaign §5 AP-126 — retail's +/// cur/local clock split is immaterial to the gates we port). Single-threaded +/// like the rest of the transport: runs only from the +/// session sweep. +/// +/// +internal sealed class TransportClock +{ + private readonly Func _timestampSource; + private readonly long _ticksPerInterval; + private long _intervalBaseTimestamp; + + /// Timestamp ticks per second of the injected source. + public long Frequency { get; } + + /// + /// Retail's 0.5-s interval counter (CurLocalInterval_.intervalID_). + /// Starts at 1; wraps with natural ushort arithmetic. + /// + public ushort IntervalId { get; private set; } + + public TransportClock( + Func? timestampSource = null, + long? frequency = null) + { + _timestampSource = timestampSource ?? Stopwatch.GetTimestamp; + Frequency = frequency ?? Stopwatch.Frequency; + if (Frequency < 2) + { + throw new ArgumentOutOfRangeException( + nameof(frequency), + "the interval clock needs at least 2 ticks per second"); + } + + _ticksPerInterval = Frequency / 2; + _intervalBaseTimestamp = _timestampSource(); + IntervalId = 1; + } + + /// Current raw timestamp from the injected source. + public long GetTimestamp() => _timestampSource(); + + /// + /// Advance by however many whole 0.5-s + /// intervals have elapsed since the last update. Called once per sweep. + /// + public void Update() + { + long elapsed = _timestampSource() - _intervalBaseTimestamp; + if (elapsed < _ticksPerInterval) + return; + + long steps = elapsed / _ticksPerInterval; + IntervalId = unchecked((ushort)(IntervalId + steps)); + _intervalBaseTimestamp += steps * _ticksPerInterval; + } +} diff --git a/src/AcDream.Core.Net/Transport/TransportStats.cs b/src/AcDream.Core.Net/Transport/TransportStats.cs new file mode 100644 index 00000000..e28f6c6a --- /dev/null +++ b/src/AcDream.Core.Net/Transport/TransportStats.cs @@ -0,0 +1,38 @@ +namespace AcDream.Core.Net.Transport; + +/// +/// Unconditional reliable-transport counters. Increment sites are NOT +/// probe-gated — the counters are plain field writes and always current, so +/// a later probe (N5's [net-tick] extension) or a connected gate can +/// read them without having been armed in advance. Printing stays +/// probe-gated at the call sites that choose to surface them. +/// +internal sealed class TransportStats +{ + /// Datagrams re-emitted in response to a server NAK. + public long ResendsSent; + + /// Inbound packets carrying RequestRetransmit. + public long NakRequestsReceived; + + /// + /// NAKed ids no longer (or never) in the sent-packet cache, dropped + /// silently instead of answering retail's RejectRetransmit + /// (divergence register TS-57 — ACE no-ops the reject, and the + /// standalone unsequenced form would trip ACE's watermark hole). + /// + public long UncachedNakIds; + + /// Inbound AckSequence values folded into the watermark + /// (explicit acks plus the NAK ids[0] implicit ack). + public long AcksConsumed; + + /// Live sent-packet cache depth — the N5 watchdog value + /// (cache=N in [net-tick]; the cache is unbounded like + /// retail's, so depth is the health signal, not a cap). + public int CacheDepth => CacheDepthSource?.Invoke() ?? 0; + + /// Wired by to the store's + /// Count. + internal Func? CacheDepthSource { get; set; } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3048b887..51d1e591 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -8,6 +8,7 @@ using AcDream.Core.Items; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; namespace AcDream.Core.Net; @@ -66,9 +67,10 @@ internal sealed class NetClientWorldSessionTransport(IPEndPoint remote) /// /// /// -/// Still deferred: retransmit handling and unsolicited-disconnect -/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout -/// are live. +/// Still deferred: inbound sequence-aligned ISAAC + client NAK +/// emission (Campaign N slices N2/N4) and unsolicited-disconnect recovery. +/// The outbound sent-packet cache + resend on server NAK (N1), ACKs, world +/// updates, chat, and retail-ordered graceful logout are live. /// /// public sealed class WorldSession : IDisposable @@ -672,12 +674,22 @@ public sealed class WorldSession : IDisposable private readonly System.Collections.Generic.HashSet _seenUnhandledOpcodes = new(); private IsaacRandom? _inboundIsaac; - private IsaacRandom? _outboundIsaac; private ushort _sessionClientId; private ushort _sessionIteration; private bool _transportNegotiated; - private uint _clientPacketSequence; - private uint _fragmentSequence = 1; + + /// + /// Campaign N Slice N1: the reliable outbound transport — outbound + /// ISAAC, packet/fragment sequences, sent-packet cache, resend on NAK. + /// Constructed at ISAAC-seeding time in ; null + /// before negotiation (reliable sends are impossible then anyway — the + /// keystream does not exist yet). + /// + private ReliableTransport? _transport; + + /// Test seam: transport counters + cache depth for the + /// conformance/loss suites. Null before negotiation. + internal ReliableTransport? Transport => _transport; // Movement sequence counters — echoed back in every MoveToState and // AutonomousPosition so the server can detect stale/reordered packets. @@ -858,14 +870,22 @@ public sealed class WorldSession : IDisposable byte[] clientSeedBytes = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed); _inboundIsaac = new IsaacRandom(serverSeedBytes); - _outboundIsaac = new IsaacRandom(clientSeedBytes); _sessionClientId = (ushort)opt.ConnectRequestClientId; // SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData // generation into connection-level control packets, including the // final disconnect. ACE currently emits iteration 1. _sessionIteration = connectRequestIteration; + // N1: the reliable transport is born at ISAAC-seeding time, owning + // the outbound keystream + packet/fragment sequences the session + // used to hold directly. 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 — + // byte-identical to the pre-N1 wire behavior. + _transport = new ReliableTransport( + new IsaacRandom(clientSeedBytes), + _sessionClientId, + datagram => _net.Send(datagram)); _transportNegotiated = true; - _clientPacketSequence = 2; // Publish only after the receiver identity and crypto state are fully // committed. A synchronous App callback may throw or request teardown; @@ -881,10 +901,13 @@ public sealed class WorldSession : IDisposable Transition(State.InCharacterSelect); - // Step 4: drain until CharacterList arrives + // 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(). while (DateTime.UtcNow < deadline && Characters is null) { PumpOnce(); + SweepTransport(); } if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); } } @@ -909,11 +932,15 @@ public sealed class WorldSession : IDisposable SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody()); - // Wait for CharacterEnterWorldServerReady (0xF7DF) + // Wait for CharacterEnterWorldServerReady (0xF7DF). Sweep inside + // the blocking pump (campaign landmine #8): the EnterWorld + // CreateObject flood — and any NAK it provokes — precedes the + // first Tick(). bool serverReady = false; while (DateTime.UtcNow < deadline && !serverReady) { var drained = PumpOnce(out var opcodes); + SweepTransport(); if (!drained) continue; foreach (var op in opcodes) if (op == 0xF7DFu) { serverReady = true; break; } @@ -1020,9 +1047,28 @@ public sealed class WorldSession : IDisposable } if (NetDiagnostics.ProbeNet) ProbeNetTickCadence(start, processed, budgetBroke); + // N1: the transport sweep runs at the end of EVERY Tick, after the + // budget break — a deferred inbound tail must not defer a due + // resend past this frame. + SweepTransport(); return processed; } + /// + /// N1: one reliable-transport pump slice (retail + /// PacketController::UseTime @ 0x005410D0 shape): interval clock + /// forward, pending NAKed resends out, acked cache pruned. Gated on + /// negotiation — ACE's Session.CheckState silently discards + /// pre-negotiation control traffic (campaign landmine #8), and the + /// transport does not exist before the ISAAC seeds do. + /// + private void SweepTransport() + { + if (!_transportNegotiated) + return; + _transport?.Sweep(); + } + // #260 probe state — only touched when NetDiagnostics.ProbeNet is set. // The inter-Tick gap doubles as a frame-stall witness: Tick runs once per // frame on the frame thread, so a GC pause or saturated frame shows up @@ -1257,6 +1303,31 @@ public sealed class WorldSession : IDisposable // acceptance, before any heavy render-thread message handling. Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp()); + PacketHeader serverHeader = dec.Packet.Header; + + // N1: consume the transport control surfaces FIRST, before the + // reflex ack below (which still fires unchanged this slice; the + // AckNakScheduler replaces it in N3). + if (_transport is { } transport) + { + // Server NAK (RequestRetransmit 0x1000): merge the requested + // ids into the pending-resend list; ids[0] doubles as retail's + // implicit cumulative ack (RecipientData::ProcessNaks + // @ 0x00547010). The resends go out on the next sweep. + if ((serverHeader.Flags & PacketHeaderFlags.RequestRetransmit) != 0 + && dec.Packet.Optional.RetransmitRequestCount > 0) + { + transport.Outbound.OnRetransmitRequest( + dec.Packet.Optional.RetransmitRequestBytes.Span, + dec.Packet.Optional.RetransmitRequestCount); + } + + // Cumulative ack (AckSequence 0x4000): wrap-safe max into the + // watermark; the cache prunes strictly below it on the sweep. + if ((serverHeader.Flags & PacketHeaderFlags.AckSequence) != 0) + transport.Outbound.OnAckSequence(dec.Packet.Optional.AckSequence); + } + // Phase 4.9: send an ACK_SEQUENCE control packet for every received // server packet with sequence > 0 and no ACK flag of its own. This // is the proper holtburger pattern (every received packet gets an @@ -1264,7 +1335,6 @@ public sealed class WorldSession : IDisposable // with "Network Timeout" because it sees no acks coming back — // which surfaces in other clients' views as the player rendering // as a stationary purple haze (loading state). - PacketHeader serverHeader = dec.Packet.Header; if (serverHeader.Sequence > 0 && (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0) { @@ -2242,28 +2312,16 @@ public sealed class WorldSession : IDisposable ProbeNetLogOutbound(gameMessageBody, queue); try { - Span datagram = stackalloc byte[ - PacketHeader.Size - + MessageFragmentHeader.MaxFragmentSize]; - int fragmentLength = - GameMessageFragment.WriteSingleFragment( - datagram.Slice(PacketHeader.Size), - _fragmentSequence++, - queue, - gameMessageBody); - var header = new PacketHeader - { - Sequence = _clientPacketSequence++, - Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, - Id = _sessionClientId, - }; - int datagramLength = PacketCodec.FinalizeInPlace( - header, - datagram, - fragmentLength, - optionalLength: 0, - _outboundIsaac); - _net.Send(datagram.Slice(0, datagramLength)); + // N1: the reliable transport owns encode + send + cache. Wire + // shape is unchanged; the datagram is additionally cached for + // resend on server NAK. Pre-negotiation reliable sends were + // always impossible (no outbound keystream existed) — the + // exception simply names the state now. + ReliableTransport transport = _transport + ?? throw new InvalidOperationException( + "reliable send before transport negotiation — " + + "Connect() must seed ISAAC first"); + transport.Outbound.SendGameMessage(gameMessageBody, queue); } catch (Exception ex) when (ProbeNetLogOutboundFault(ex)) { @@ -2295,8 +2353,10 @@ public sealed class WorldSession : IDisposable detail = $" act=0x{act:X4} gseq={gseq}"; } Console.WriteLine( - $"[net-out] op=0x{op:X4}{detail} q={queue} fseq={_fragmentSequence}" - + $" pseq={_clientPacketSequence} len={body.Length}" + $"[net-out] op=0x{op:X4}{detail} q={queue}" + + $" fseq={_transport?.Outbound.FragmentSequence ?? 0}" + + $" pseq={_transport?.Outbound.PeekNextPacketSequence ?? 0}" + + $" len={body.Length}" + $" tid={Environment.CurrentManagedThreadId} st={CurrentState}"); } @@ -2345,10 +2405,10 @@ public sealed class WorldSession : IDisposable // Holtburger uses current_client_sequence (= packet_sequence - 1) for // ack headers. We mirror that — acks borrow the most recently issued - // client sequence rather than consuming a new one. - uint ackHeaderSequence = _clientPacketSequence > 0 - ? _clientPacketSequence - 1 - : 0u; + // client sequence (the transport's HighestIdSent) rather than + // consuming a new one. N1 keeps this behaviorally EXACTLY as-is; + // the AckNakScheduler arrives in N3. + uint ackHeaderSequence = _transport?.HighestIdSent ?? 0u; var header = new PacketHeader { @@ -2433,6 +2493,9 @@ public sealed class WorldSession : IDisposable } _netCancel.Dispose(); + // N1: return every rented sent-packet cache buffer before the + // socket goes away. + _transport?.Dispose(); _net.Dispose(); Transition(State.Disconnected); } diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs index 24c93b6a..60f377ab 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModel.cs @@ -556,21 +556,32 @@ internal sealed class AceSessionModel if (fragment.Header.Count != 1) { - // :489-518 — split message, buffered by fragment sequence. - if (!_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer)) + // ACE's two literal branches, kept separate because only ONE of + // them checks Complete: + if (_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer)) { - buffer = new PartialC2SMessage(fragment.Header.Count); - _partialFragments.Add(fragment.Header.Sequence, buffer); + // :495-507 — existing buffer: add, then check Complete. + buffer.AddFragment(fragment.Header.Index, fragment.Payload); + if (buffer.Complete) + { + // :504-506 — TryGetMessage may return null (assembled + // stream under 4 bytes, MessageBuffer.cs:49-50) but the + // buffer is removed EITHER WAY. + message = buffer.TryGetMessage(); + _partialFragments.Remove(fragment.Header.Sequence); + } } - - buffer.AddFragment(fragment.Header.Index, fragment.Payload); - if (buffer.Complete) + else { - // :504-506 — TryGetMessage may return null (assembled stream - // under 4 bytes, MessageBuffer.cs:49-50) but the buffer is - // removed EITHER WAY. - message = buffer.TryGetMessage(); - _partialFragments.Remove(fragment.Header.Sequence); + // :509-518 — new buffer: construct + AddFragment + TryAdd, + // WITHOUT checking Complete. A fragment whose Count can + // never be reached from here (Count == 0: AddFragment + // refuses to add to an already-"Complete" buffer) parks a + // dead buffer in partialFragments forever — ACE + // bug-for-bug. + var newBuffer = new PartialC2SMessage(fragment.Header.Count); + newBuffer.AddFragment(fragment.Header.Index, fragment.Payload); + _partialFragments.TryAdd(fragment.Header.Sequence, newBuffer); } } else if (fragment.Payload.Length >= 4) diff --git a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs index 72456f01..277a637c 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/AceSessionModelTests.cs @@ -453,14 +453,16 @@ public sealed class AceSessionModelTests // ...while ACE's ClientPacketFragment.Unpack (:10-23) only checks // 16 ≤ Size ≤ 464, so the packet is parsed, CRC-verified and - // processed. ProcessFragment takes the split branch (Count != 1), the - // buffer is Complete at zero fragments, TryGetMessage returns null, - // and the whole thing evaporates — the packet still burns its - // keystream word and still advances the watermark. + // processed. ProcessFragment takes the split branch (Count != 1) + // and its NEW-buffer arm (:509-518), which never checks Complete: + // MessageBuffer.AddFragment refuses to add to the already- + // "Complete" zero-count buffer, no message ever dispatches, and the + // dead buffer stays PARKED in partialFragments forever. The packet + // still burns its keystream word and still advances the watermark. model.Receive(zeroCount); Assert.Equal(0, model.CrcDropCount); Assert.Empty(model.DispatchedMessages); - Assert.Equal(0, model.PartialFragmentBufferCount); + Assert.Equal(1, model.PartialFragmentBufferCount); Assert.Equal(0u, model.LastReceivedFragmentSequence); Assert.Equal(2u, model.LastReceivedPacketSequence); } diff --git a/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs new file mode 100644 index 00000000..7a03a887 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs @@ -0,0 +1,660 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Net; +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; + +namespace AcDream.Core.Net.Tests.Transport; + +/// +/// Campaign N Slice N1 — the outbound sent-packet cache + resend on NAK. +/// Unit tests pin the store/clock/sequence primitives; conformance tests +/// grade the resend against the N0 ACE-behaviour double (the rebuilt-header +/// resend must verify under with the ORIGINAL +/// keystream word — campaign landmines #1/#2). +/// +public sealed class OutboundReliableTransportTests +{ + private const uint ClientSeed = 0x11AA22BBu; + private const uint ServerSeed = 0x33CC44DDu; + private const uint ClientId = 0x1234u; + private const ulong Cookie = 0xFEEDFACECAFEBABEUL; + + // ===================================================================== + // SequenceMath — TimeStampUtils::lhs_newer @ 0x00543890 + // ===================================================================== + + [Fact] + public void SequenceMath_IsNewer_IsWrapSafe() + { + Assert.True(SequenceMath.IsNewer(2u, 1u)); + Assert.False(SequenceMath.IsNewer(1u, 2u)); + Assert.False(SequenceMath.IsNewer(7u, 7u)); + + // Across the 32-bit wrap: 1 is newer than 0xFFFFFFFF. + Assert.True(SequenceMath.IsNewer(1u, uint.MaxValue)); + Assert.False(SequenceMath.IsNewer(uint.MaxValue, 1u)); + + Assert.Equal(5u, SequenceMath.Max(5u, 3u)); + Assert.Equal(5u, SequenceMath.Max(3u, 5u)); + // Wrap-safe max: a small post-wrap value beats a huge pre-wrap one. + Assert.Equal(5u, SequenceMath.Max(0xFFFFFFF6u, 5u)); + } + + // ===================================================================== + // TransportClock — ClientFlowQueue::IncrementLocalInterval @ 0x00547F10 + // ===================================================================== + + [Fact] + public void TransportClock_StartsAtOne_AdvancesEveryHalfSecond_AndWraps() + { + var virtualClock = new VirtualClock(); + var clock = new TransportClock( + virtualClock.GetTimestamp, + virtualClock.Frequency); + Assert.Equal((ushort)1, clock.IntervalId); + + // Under half a second: no advance. + virtualClock.Advance(TimeSpan.FromSeconds(0.49)); + clock.Update(); + Assert.Equal((ushort)1, clock.IntervalId); + + // Crossing 0.5 s advances one interval. + virtualClock.Advance(TimeSpan.FromSeconds(0.01)); + clock.Update(); + Assert.Equal((ushort)2, clock.IntervalId); + + // A long gap advances by the whole number of elapsed intervals, + // preserving the fractional remainder. + virtualClock.Advance(TimeSpan.FromSeconds(2.75)); + clock.Update(); + Assert.Equal((ushort)7, clock.IntervalId); + virtualClock.Advance(TimeSpan.FromSeconds(0.25)); + clock.Update(); + Assert.Equal((ushort)8, clock.IntervalId); + + // Natural ushort wrap: 65531 more intervals take 8 → 3 (mod 65536). + virtualClock.Advance(TimeSpan.FromSeconds(0.5 * 65531)); + clock.Update(); + Assert.Equal((ushort)3, clock.IntervalId); + } + + // ===================================================================== + // SentPacketStore — AddSentPacket @ 0x0054AB00 / Flush @ 0x0054ACD0 + // ===================================================================== + + [Fact] + public void SentPacketStore_FifoContainsAndStrictFlush() + { + var pool = new CountingPool(); + using var store = new SentPacketStore(pool); + store.Add(RentedEntry(pool, 2u), optionalLength: 0); + store.Add(RentedEntry(pool, 3u), optionalLength: 0); + store.Add(RentedEntry(pool, 4u), optionalLength: 0); + Assert.Equal(3, store.Count); + Assert.True(store.Contains(3u)); + Assert.False(store.Contains(5u)); + Assert.True(store.TryGet(2u, out SentPacketStore.CachedPacket got)); + Assert.Equal(2u, got.Sequence); + + // STRICTLY older: the watermark entry itself survives + // (SentPacketStore::Flush breaks at seqNum_ == watermark). + store.FlushOlderThan(3u); + Assert.Equal(2, store.Count); + Assert.False(store.Contains(2u)); + Assert.True(store.Contains(3u)); + Assert.True(store.Contains(4u)); + Assert.Equal(1, pool.Returned); + + store.FlushOlderThan(5u); + Assert.Equal(0, store.Count); + Assert.Equal(3, pool.Returned); + Assert.Equal(pool.Rented, pool.Returned); + } + + [Fact] + public void SentPacketStore_FlushIsWrapSafe_AcrossTheSequenceWrap() + { + var pool = new CountingPool(); + using var store = new SentPacketStore(pool); + // Retail wraps 0xFFFFFFFF → 1 (never 0). + store.Add(RentedEntry(pool, 0xFFFFFFFEu), optionalLength: 0); + store.Add(RentedEntry(pool, 0xFFFFFFFFu), optionalLength: 0); + store.Add(RentedEntry(pool, 1u), optionalLength: 0); + store.Add(RentedEntry(pool, 2u), optionalLength: 0); + + // Watermark 1 (post-wrap): both pre-wrap entries are strictly + // older; 1 and 2 survive. A raw `<` compare would flush nothing. + store.FlushOlderThan(1u); + Assert.Equal(2, store.Count); + Assert.False(store.Contains(0xFFFFFFFEu)); + Assert.False(store.Contains(0xFFFFFFFFu)); + Assert.True(store.Contains(1u)); + Assert.True(store.Contains(2u)); + Assert.Equal(2, pool.Returned); + + store.FlushOlderThan(3u); + Assert.Equal(0, store.Count); + Assert.Equal(pool.Rented, pool.Returned); + } + + [Fact] + public void SentPacketStore_Dispose_ReturnsEveryRentedBuffer() + { + var pool = new CountingPool(); + var store = new SentPacketStore(pool); + store.Add(RentedEntry(pool, 2u), optionalLength: 0); + store.Add(RentedEntry(pool, 3u), optionalLength: 0); + store.Dispose(); + Assert.Equal(pool.Rented, pool.Returned); + } + + [Fact] + public void SentPacketStore_Add_AssertsNoOptionalHeaders() + { + var pool = new CountingPool(); + using var store = new SentPacketStore(pool); + SentPacketStore.CachedPacket entry = RentedEntry(pool, 2u); + Assert.Throws( + () => store.Add(entry, optionalLength: 4)); + pool.Return(entry.Buffer); // the failed Add never took ownership + } + + // ===================================================================== + // OutboundFlowQueue — resend header rebuild (landmines #1/#2/#3) + // ===================================================================== + + [Fact] + public void Resend_RebuildsHeaderOnly_FlagsTimeChecksum_BodyBitIdentical() + { + (OutboundFlowQueue queue, VirtualClock virtualClock, + TransportClock clock, TransportStats stats, List sent) = + CreateQueue(); + + queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); + byte[] original = Assert.Single(sent); + PacketHeader originalHeader = PacketHeader.Unpack(original); + Assert.Equal(2u, originalHeader.Sequence); + Assert.Equal( + PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, + originalHeader.Flags); + Assert.Equal((ushort)0, originalHeader.Time); + + // 1.2 s later (interval id 1 → 3) the server NAKs sequence 2. + virtualClock.Advance(TimeSpan.FromSeconds(1.2)); + clock.Update(); + Nak(queue, 2u); + sent.Clear(); + queue.TransmitPendingResends(); + + byte[] resent = Assert.Single(sent); + PacketHeader resentHeader = PacketHeader.Unpack(resent); + + // Flags become EXACTLY Retransmission|EncryptedChecksum|BlobFragments + // (= 7 with fragments); Time advances to the current interval; + // Sequence/Id/Iteration/DataSize stay verbatim. + Assert.Equal( + PacketHeaderFlags.Retransmission + | PacketHeaderFlags.EncryptedChecksum + | PacketHeaderFlags.BlobFragments, + resentHeader.Flags); + Assert.Equal((uint)7, (uint)resentHeader.Flags); + Assert.Equal((ushort)3, resentHeader.Time); + Assert.Equal(originalHeader.Sequence, resentHeader.Sequence); + Assert.Equal(originalHeader.DataSize, resentHeader.DataSize); + Assert.Equal(originalHeader.Id, resentHeader.Id); + Assert.Equal(originalHeader.Iteration, resentHeader.Iteration); + + // Checksum = FRESH header hash + the stored sealed checksum, where + // sealed = originalChecksum − originalHeaderHash (landmine #1). + uint sealedChecksum = + originalHeader.Checksum - originalHeader.CalculateHeaderHash32(); + Assert.Equal( + resentHeader.CalculateHeaderHash32() + sealedChecksum, + resentHeader.Checksum); + + // Body bytes bit-identical. + Assert.Equal( + original.AsSpan(PacketHeader.Size).ToArray(), + resent.AsSpan(PacketHeader.Size).ToArray()); + Assert.Equal(1, stats.ResendsSent); + Assert.Equal(1, stats.NakRequestsReceived); + Assert.Equal(0, stats.UncachedNakIds); + } + + [Fact] + public void BuildResendHeader_WithoutFragments_FlagsAreExactlyThree() + { + // Fragmentless reliable packets do not exist on the N1 send path + // (every reliable message rides a fragment), but the rebuild rule is + // pinned for both shapes: 3 without fragments, 7 with. + byte[] buffer = new byte[PacketHeader.Size]; + var header = new PacketHeader + { + Sequence = 9u, + Flags = PacketHeaderFlags.EncryptedChecksum, + Id = 0x1234, + DataSize = 0, + }; + header.Pack(buffer); + var cached = new SentPacketStore.CachedPacket( + 9u, buffer, bodyLength: 0, sealedChecksum: 0xDEADBEEFu, + isaacKey: 0u, hasFragments: false); + + PacketHeader rebuilt = + OutboundFlowQueue.BuildResendHeader(in cached, intervalId: 42); + Assert.Equal( + PacketHeaderFlags.Retransmission | PacketHeaderFlags.EncryptedChecksum, + rebuilt.Flags); + Assert.Equal((uint)3, (uint)rebuilt.Flags); + Assert.Equal((ushort)42, rebuilt.Time); + Assert.Equal(9u, rebuilt.Sequence); + Assert.Equal( + rebuilt.CalculateHeaderHash32() + 0xDEADBEEFu, + rebuilt.Checksum); + } + + [Fact] + public void Resend_ConsumesNoOutboundIsaacWord() + { + (OutboundFlowQueue queue, _, _, _, List sent) = CreateQueue(); + IsaacRandom shadow = MakeIsaac(ClientSeed); + uint w1 = shadow.Next(); + uint w2 = shadow.Next(); + uint w3 = shadow.Next(); + + queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); // seq 2, w1 + queue.SendGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); // seq 3, w2 + Assert.Equal(w1, ExtractIsaacKey(sent[0])); + Assert.Equal(w2, ExtractIsaacKey(sent[1])); + + // Resend of seq 2 reuses w1 — no keystream word drawn (landmine #2). + Nak(queue, 2u); + sent.Clear(); + queue.TransmitPendingResends(); + Assert.Equal(w1, ExtractIsaacKey(Assert.Single(sent))); + + // The wheel did not move: the next fresh packet takes w3. + sent.Clear(); + queue.SendGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); // seq 4, w3 + Assert.Equal(w3, ExtractIsaacKey(Assert.Single(sent))); + } + + [Fact] + public void Nak_ForUncachedId_SendsNothing_AndCounts() + { + (OutboundFlowQueue queue, _, _, TransportStats stats, List sent) = + CreateQueue(); + queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); // seq 2 + sent.Clear(); + + // Id 40 was never sent: dropped silently + counted (TS-57 — retail + // answers RejectRetransmit; ACE no-ops it and the standalone form + // would trip the watermark hole). + Nak(queue, 40u); + queue.TransmitPendingResends(); + Assert.Empty(sent); + Assert.Equal(1, stats.UncachedNakIds); + Assert.Equal(0, stats.ResendsSent); + Assert.Equal(0, queue.PendingResendCount); + } + + [Fact] + public void NakFirstId_FoldsTheAckWatermark_AndSweepPrunesStrictlyBelow() + { + (OutboundFlowQueue queue, _, _, TransportStats stats, List sent) = + CreateQueue(); + queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); // seq 2 + queue.SendGameMessage(MakeMessage(0xB2), GameMessageGroup.UIQueue); // seq 3 + queue.SendGameMessage(MakeMessage(0xC3), GameMessageGroup.UIQueue); // seq 4 + Assert.Equal(3, queue.CacheDepth); + sent.Clear(); + + // ids[0] = 4 doubles as the implicit cumulative ack + // (RecipientData::ProcessNaks @ 0x00547010): everything strictly + // below 4 prunes on the sweep; 4 itself is served. + Nak(queue, 4u); + Assert.Equal(4u, queue.AckWatermark); + queue.TransmitPendingResends(); + Assert.Equal(4u, PacketHeader.Unpack(Assert.Single(sent)).Sequence); + Assert.Equal(1, queue.CacheDepth); + Assert.True(stats.AcksConsumed >= 1); + } + + [Fact] + public void OnAckSequence_IsWrapSafeMax_AndNeverRegresses() + { + (OutboundFlowQueue queue, _, _, _, _) = CreateQueue(); + queue.OnAckSequence(10u); + Assert.Equal(10u, queue.AckWatermark); + queue.OnAckSequence(3u); // stale ack must not roll the watermark back + Assert.Equal(10u, queue.AckWatermark); + + // Across the wrap: walk the watermark up in half-window-safe steps + // (like a live sequence stream does), then a small post-wrap value + // is NEWER than the huge pre-wrap one — and the reverse is stale. + (OutboundFlowQueue wrapQueue, _, _, _, _) = CreateQueue(); + wrapQueue.OnAckSequence(0x60000000u); + wrapQueue.OnAckSequence(0xC0000000u); + wrapQueue.OnAckSequence(0xFFFFFFF6u); + Assert.Equal(0xFFFFFFF6u, wrapQueue.AckWatermark); + wrapQueue.OnAckSequence(5u); // newer across the wrap + Assert.Equal(5u, wrapQueue.AckWatermark); + wrapQueue.OnAckSequence(0xFFFFFFF6u); // now stale — must not regress + Assert.Equal(5u, wrapQueue.AckWatermark); + } + + // ===================================================================== + // Conformance against the N0 ACE-behaviour double + // ===================================================================== + + [Fact] + public void RebuiltResend_VerifiesUnderAceCrypto_WithTheOriginalKey() + { + (AceSessionModel model, _) = CreateNegotiatedModel(); + (OutboundFlowQueue queue, _, _, _, List sent) = CreateQueue(); + + // Four reliable packets, seq 2..5; seq 3 is "lost". + queue.SendGameMessage(MakeMessage(2), GameMessageGroup.UIQueue); + queue.SendGameMessage(MakeMessage(3), GameMessageGroup.UIQueue); + queue.SendGameMessage(MakeMessage(4), GameMessageGroup.UIQueue); + queue.SendGameMessage(MakeMessage(5), GameMessageGroup.UIQueue); + + model.Receive(sent[0]); // seq 2 in order + model.Receive(sent[2]); // seq 4: buffered, gap of one — no NAK yet + model.Receive(sent[3]); // seq 5: desired+2 ≤ arrived → NAK fires + model.Update(); + byte[] nak = Assert.Single( + model.TakePendingDatagrams(), + d => PacketHeader.Unpack(d).Flags + == PacketHeaderFlags.RequestRetransmit); + + // Feed the genuine ACE NAK bytes through the same parse the session + // uses, then sweep: exactly one rebuilt-header resend goes out. + PacketCodec.PacketDecodeResult decodedNak = + PacketCodec.TryDecode(nak, inboundIsaac: null); + Assert.True(decodedNak.IsOk, decodedNak.Error.ToString()); + uint[] ids = decodedNak.Packet!.Optional.RetransmitRequests.ToArray(); + Assert.Equal(new uint[] { 3u }, ids); + sent.Clear(); + Nak(queue, ids); + queue.TransmitPendingResends(); + byte[] resent = Assert.Single(sent); + Assert.Equal( + PacketHeaderFlags.Retransmission + | PacketHeaderFlags.EncryptedChecksum + | PacketHeaderFlags.BlobFragments, + PacketHeader.Unpack(resent).Flags); + + // ACE verifies the rebuilt form with the PARKED ORIGINAL key: the + // 256-key window fully recovers, no orphan, ordering restored. + model.Receive(resent); + Assert.Equal( + new byte[] { 2, 3, 4, 5 }, + model.DispatchedMessages.Select(m => m[0]).ToArray()); + Assert.Equal(5u, model.LastReceivedPacketSequence); + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(0, model.DuplicateDropCount); + Assert.Equal(256, model.Crypto.Headroom); + Assert.Equal(0, model.Crypto.OrphanCount); + + // ids[0] = 3 folded as the implicit ack: seq 2 pruned on the sweep, + // 3..5 still cached. + Assert.Equal(3, queue.CacheDepth); + } + + /// + /// The #260 fix end-to-end: a REAL against + /// the ACE double, one C2S game-action datagram dropped by the link — + /// ACE NAKs the gap, the session resends from the cache on its Tick + /// sweep, and every message dispatches in order with the crypto window + /// intact. + /// + [Fact] + public void LostGameAction_IsResentOnNak_AllMessagesDispatchInOrder() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + try + { + session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + int baselineDispatched = transport.Model.DispatchedMessages.Count; + + // Ten game actions; the link eats C2S datagram #5 of the burst. + // No Tick runs during the burst, so DropNext deterministically + // hits the fifth SendTalk datagram. + var expectedBodies = new List(); + for (int i = 0; i < 10; i++) + { + if (i == 4) + transport.Link.DropNext(LinkDirection.ClientToServer); + string text = $"msg {i}"; + expectedBodies.Add(ChatRequests.BuildTalk((uint)(i + 1), text)); + session.SendTalk(text); + } + + // Pump: the NAK is already queued S2C; Tick consumes it and the + // end-of-Tick sweep resends the cached datagram. + DateTime deadline = DateTime.UtcNow.AddSeconds(10); + while (transport.Model.DispatchedMessages.Count + < baselineDispatched + 10 + && DateTime.UtcNow < deadline) + { + session.Tick(); + Thread.Sleep(5); + } + + // All ten dispatched, byte-identical, in fragment order. + Assert.Equal( + expectedBodies, + transport.Model.DispatchedMessages + .Skip(baselineDispatched) + .ToList()); + + // Exactly one resend healed exactly one loss. + Assert.Equal(1, transport.Link.DroppedCount(LinkDirection.ClientToServer)); + Assert.Equal(1, session.Transport!.Stats.ResendsSent); + Assert.Equal(1, session.Transport.Stats.NakRequestsReceived); + Assert.Equal(0, session.Transport.Stats.UncachedNakIds); + + // The session survived and the crypto window is intact. + Assert.False(transport.Model.IsTerminated); + Assert.Equal(0, transport.Model.CrcDropCount); + Assert.Equal(0, transport.Model.DuplicateDropCount); + Assert.Equal(256, transport.Model.Crypto.Headroom); + Assert.Equal(0, transport.Model.Crypto.OrphanCount); + } + finally + { + session.Dispose(); + } + + Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); + } + + // ===================================================================== + // Zero-alloc steady state + // ===================================================================== + + [Fact] + public void SendGameMessage_SteadyState_AllocatesNothingOnceThePoolWarms() + { + var stats = new TransportStats(); + var virtualClock = new VirtualClock(); + var clock = new TransportClock( + virtualClock.GetTimestamp, + virtualClock.Frequency); + var queue = new OutboundFlowQueue( + MakeIsaac(ClientSeed), + (ushort)ClientId, + clock, + stats, + static _ => { }); + byte[] body = MakeMessage(0x42); + + // Warm the pool + queue/dictionary capacity in the same + // send → ack → sweep rhythm the measurement uses. + for (int i = 0; i < 128; i++) + { + queue.SendGameMessage(body, GameMessageGroup.UIQueue); + queue.OnAckSequence(queue.HighestIdSent); + queue.TransmitPendingResends(); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + queue.SendGameMessage(body, GameMessageGroup.UIQueue); + queue.OnAckSequence(queue.HighestIdSent); + queue.TransmitPendingResends(); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.Equal(0, allocated); + queue.Dispose(); + } + + // ===================================================================== + // Fixture helpers + // ===================================================================== + + private (OutboundFlowQueue Queue, VirtualClock VirtualClock, + TransportClock Clock, TransportStats Stats, List Sent) + CreateQueue() + { + var virtualClock = new VirtualClock(); + var clock = new TransportClock( + virtualClock.GetTimestamp, + virtualClock.Frequency); + var stats = new TransportStats(); + var sent = new List(); + var queue = new OutboundFlowQueue( + MakeIsaac(ClientSeed), + (ushort)ClientId, + clock, + stats, + datagram => sent.Add(datagram.ToArray())); + return (queue, virtualClock, clock, stats, sent); + } + + /// Deliver a NAK id list the way ProcessDatagram does: raw + /// little-endian u32 ids + count. + private static void Nak(OutboundFlowQueue queue, params uint[] ids) + { + byte[] bytes = new byte[ids.Length * 4]; + for (int i = 0; i < ids.Length; i++) + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(i * 4), ids[i]); + queue.OnRetransmitRequest(bytes, ids.Length); + } + + /// An 8-byte message body whose first byte is a test marker. + private static byte[] MakeMessage(byte marker) => + new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 }; + + private static IsaacRandom MakeIsaac(uint seed) + { + Span seedBytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); + return new IsaacRandom(seedBytes); + } + + /// A negotiated ACE double, matching the AceSessionModelTests + /// fixture: LoginRequest → ConnectRequest (discarded) → ConnectResponse + /// → immediate first TimeSync (discarded; S2C seq 2). + private static (AceSessionModel Model, VirtualClock Clock) CreateNegotiatedModel() + { + var clock = new VirtualClock(); + var model = new AceSessionModel(clock, ClientSeed, ServerSeed, ClientId, Cookie); + model.LoginRequestReceived += model.SendConnectRequest; + + byte[] login = PacketCodec.Encode( + new PacketHeader { Flags = PacketHeaderFlags.LoginRequest }, + LoginRequest.Build("testaccount", "testpassword", 1234), + outboundIsaac: null); + model.Receive(login); + model.Update(); + model.TakePendingDatagrams(); + + byte[] cookieBody = new byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(cookieBody, Cookie); + byte[] connectResponse = PacketCodec.Encode( + new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse }, + cookieBody, + outboundIsaac: null); + model.Receive(connectResponse); + model.Update(); + model.TakePendingDatagrams(); + + return (model, clock); + } + + /// + /// Recover the ISAAC word from an encrypted datagram's checksum: + /// key = (checksum − headerHash) ^ payloadHash (ClientPacket.cs:142). + /// + private static uint ExtractIsaacKey(byte[] datagram) + { + PacketHeader header = PacketHeader.Unpack(datagram); + ReadOnlySpan body = datagram.AsSpan(PacketHeader.Size, header.DataSize); + var optional = new PacketHeaderOptional(); + int consumed = optional.Parse(body, header.Flags); + Assert.True(consumed >= 0); + uint payloadHash = optional.CalculateHash32(); + if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0) + { + ReadOnlySpan remaining = body.Slice(consumed); + while (!remaining.IsEmpty) + { + (MessageFragment? fragment, int fragmentBytes) = + MessageFragment.TryParse(remaining); + Assert.NotNull(fragment); + payloadHash += PacketCodec.CalculateFragmentHash32(fragment!.Value); + remaining = remaining.Slice(fragmentBytes); + } + } + + return (header.Checksum - header.CalculateHeaderHash32()) ^ payloadHash; + } + + /// A cache entry whose buffer is rented from + /// , so rent/return balance is assertable. + private static SentPacketStore.CachedPacket RentedEntry( + CountingPool pool, + uint sequence) + { + byte[] buffer = pool.Rent(PacketHeader.Size + 24); + return new SentPacketStore.CachedPacket( + sequence, + buffer, + bodyLength: 24, + sealedChecksum: 0u, + isaacKey: 0u, + hasFragments: true); + } + + /// ArrayPool wrapper counting rents/returns for balance + /// assertions. + private sealed class CountingPool : ArrayPool + { + public int Rented { get; private set; } + public int Returned { get; private set; } + + public override byte[] Rent(int minimumLength) + { + Rented++; + return Shared.Rent(minimumLength); + } + + public override void Return(byte[] array, bool clearArray = false) + { + Returned++; + Shared.Return(array, clearArray); + } + } +}