feat(net): N1 - outbound sent-packet cache + resend on NAK
Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.
New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
`intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
@ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
@ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
@ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
store, the wrap-safe sorted dedup pending-resend list
(FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
Cache commit happens AFTER a successful send
(FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
NAK ids[0] folds into the watermark as retail's implicit cumulative ack
(RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
with fragments), Time = current interval id, Sequence/Id/Iteration/
DataSize verbatim, checksum = fresh header hash + stored sealed checksum
(FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
original ISAAC key rides inside the sealed value - no new keystream word
is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
this slice.
- TransportStats: unconditional counters (ResendsSent,
NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.
PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.
WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.
Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.
N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.
Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.
Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e395861053
commit
43e60a6971
13 changed files with 1491 additions and 65 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 | — | |
|
||||
|
|
|
|||
|
|
@ -561,7 +561,35 @@ public static class PacketCodec
|
|||
Span<byte> datagram,
|
||||
int bodyLength,
|
||||
int optionalLength,
|
||||
IsaacRandom? outboundIsaac)
|
||||
IsaacRandom? outboundIsaac) =>
|
||||
FinalizeInPlace(
|
||||
header,
|
||||
datagram,
|
||||
bodyLength,
|
||||
optionalLength,
|
||||
outboundIsaac,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="FinalizeInPlace(PacketHeader, Span{byte}, int, int, IsaacRandom?)"/>
|
||||
/// plus the two values the sent-packet cache needs for a header-rebuilt
|
||||
/// resend (Campaign N §2.1): <paramref name="isaacKeyUsed"/> is the
|
||||
/// keystream word this encode consumed (0 for cleartext), and
|
||||
/// <paramref name="sealedChecksum"/> is the checksum value BEFORE the
|
||||
/// header hash is added — retail <c>NetPacket::checksum_</c>:
|
||||
/// <c>payloadHash</c> for cleartext, <c>isaacKey ^ payloadHash</c> for
|
||||
/// encrypted. A resend recomputes only the header hash and adds this
|
||||
/// stored sealed value, reusing the original key (landmines #1/#2).
|
||||
/// </summary>
|
||||
internal static int FinalizeInPlace(
|
||||
PacketHeader header,
|
||||
Span<byte> 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;
|
||||
}
|
||||
|
|
|
|||
310
src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs
Normal file
310
src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Sends one finalized datagram to the wire. Span-shaped so the
|
||||
/// send path stays allocation-free.</summary>
|
||||
internal delegate void DatagramSendDelegate(ReadOnlySpan<byte> datagram);
|
||||
|
||||
/// <summary>
|
||||
/// The outbound half of retail's reliable transport
|
||||
/// (<c>RecipientData</c> + <c>ClientFlowQueue</c> + <c>SentPacketStore</c>
|
||||
/// under <c>PacketController</c>): owns the outbound ISAAC keystream, the
|
||||
/// reliable packet sequence (<c>highestIDSent_</c>), the fragment sequence,
|
||||
/// the sent-packet cache, the pending-resend id list, and the cumulative-ack
|
||||
/// watermark (<c>flushNum_</c>).
|
||||
///
|
||||
/// <para>Ported rules (campaign doc §2.1):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Every reliable packet is cached AFTER a successful send
|
||||
/// (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, cache commit at
|
||||
/// <c>0x00547C85</c> → <c>SentPacketStore::AddSentPacket @ 0x0054AB00</c>).</item>
|
||||
/// <item>Server <c>RequestRetransmit</c> ids merge-insert wrap-safe sorted
|
||||
/// with dedup (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c>); <c>ids[0]</c>
|
||||
/// doubles as an implicit cumulative ack
|
||||
/// (<c>RecipientData::ProcessNaks @ 0x00547010</c>).</item>
|
||||
/// <item>A resend re-emits the cached body with a REBUILT 20-byte header —
|
||||
/// flags <c>Retransmission|EncryptedChecksum</c> (plus <c>BlobFragments</c>
|
||||
/// when the body has fragments), <c>Time</c> = the CURRENT interval id,
|
||||
/// <c>Sequence</c>/<c>DataSize</c> verbatim, checksum = fresh header hash +
|
||||
/// stored sealed checksum (<c>FlowQueue::TransmitAcks @ 0x005485B0</c> /
|
||||
/// <c>DequeueAck @ 0x005472F0</c>). The original ISAAC key is reused via
|
||||
/// the stored sealed checksum — never a new keystream word
|
||||
/// (<c>CryptoSystem::EncryptData @ 0x0065FF40</c>, non-null key path;
|
||||
/// campaign landmines #1/#2).</item>
|
||||
/// <item><c>AckSequence</c> folds wrap-safe max into the watermark; the
|
||||
/// cache prunes STRICTLY older (<c>SentPacketStore::Flush @ 0x0054ACD0</c>).
|
||||
/// No timer-based resend exists — resend only on explicit NAK (landmine #3).</item>
|
||||
/// <item>Sequence allocation: <c>highestIDSent_</c> starts 1, pre-increment,
|
||||
/// wrap 0xFFFFFFFF → 1 (never 0).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Single-threaded by design (the ISAAC keystream is order-sensitive):
|
||||
/// every member runs on the session's frame thread, matching the existing
|
||||
/// <c>WorldSession</c> send discipline.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<byte> _pool;
|
||||
private readonly ushort _sessionClientId;
|
||||
|
||||
/// <summary>Wrap-safe sorted pending NAKed ids awaiting the next sweep
|
||||
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c> merge-insert).</summary>
|
||||
private readonly List<uint> _pendingResends = new();
|
||||
|
||||
/// <summary>Retail <c>highestIDSent_</c> — 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.</summary>
|
||||
public uint HighestIdSent { get; private set; }
|
||||
|
||||
/// <summary>The fragment sequence the NEXT reliable message will use.
|
||||
/// Starts 1, exactly like the pre-N1 <c>WorldSession</c> field.</summary>
|
||||
public uint FragmentSequence { get; private set; }
|
||||
|
||||
/// <summary>Retail <c>flushNum_</c> — the wrap-safe cumulative-ack
|
||||
/// watermark; the cache holds everything at or above it.</summary>
|
||||
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<byte>? 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<byte>.Shared;
|
||||
_store = new SentPacketStore(_pool);
|
||||
HighestIdSent = highestIdSent;
|
||||
FragmentSequence = fragmentSequence;
|
||||
}
|
||||
|
||||
/// <summary>The sequence the next reliable packet will carry —
|
||||
/// pre-increment with retail's 0xFFFFFFFF → 1 wrap (never 0).</summary>
|
||||
public uint PeekNextPacketSequence => NextSequenceAfter(HighestIdSent);
|
||||
|
||||
private static uint NextSequenceAfter(uint sequence) =>
|
||||
sequence == uint.MaxValue ? 1u : sequence + 1u;
|
||||
|
||||
/// <summary>
|
||||
/// 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 — <c>FlowQueue::TransmitNewPackets @ 0x00547C85</c>).
|
||||
/// Wire shape is byte-identical to the pre-N1 <c>WorldSession</c> path:
|
||||
/// flags <c>BlobFragments|EncryptedChecksum</c>, <c>Time</c>/<c>Iteration</c>
|
||||
/// zero, session client id, one ISAAC word.
|
||||
/// </summary>
|
||||
public void SendGameMessage(
|
||||
ReadOnlySpan<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consume an inbound cumulative ack: wrap-safe max into the watermark
|
||||
/// (<c>RecipientData::ProcessNaks @ 0x00547010</c> fold shape). Pruning
|
||||
/// happens on the next sweep.
|
||||
/// </summary>
|
||||
public void OnAckSequence(uint ackSequence)
|
||||
{
|
||||
_stats.AcksConsumed++;
|
||||
AckWatermark = SequenceMath.Max(AckWatermark, ackSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consume an inbound <c>RequestRetransmit</c> id list (raw
|
||||
/// little-endian u32 ids, count entries — the borrowed optional header's
|
||||
/// <c>RetransmitRequestBytes</c>/<c>RetransmitRequestCount</c> pair).
|
||||
/// Cached ids merge-insert into the pending-resend list
|
||||
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c>); ids no longer cached
|
||||
/// are dropped silently and counted — retail answers
|
||||
/// <c>RejectRetransmit</c> (divergence register TS-57). <c>ids[0]</c>
|
||||
/// also folds into the ack watermark as retail's implicit cumulative ack
|
||||
/// (<c>RecipientData::ProcessNaks @ 0x00547010</c>).
|
||||
/// </summary>
|
||||
public void OnRetransmitRequest(ReadOnlySpan<byte> 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++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wrap-safe sorted insert with dedup
|
||||
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c>).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-sweep resend pass (<c>FlowQueue::TransmitAcks @ 0x005485B0</c>
|
||||
/// / <c>DequeueAck @ 0x005472F0</c>): 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).
|
||||
/// </summary>
|
||||
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++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild the 20-byte resend header (campaign §2.1 / landmine #1 —
|
||||
/// resends are NOT byte-identical): flags become exactly
|
||||
/// <c>Retransmission|EncryptedChecksum</c> (=3; |<c>BlobFragments</c>
|
||||
/// =7 with fragments), <c>Time</c> advances to the current interval id
|
||||
/// (retail behavior; ACE ignores inbound Time), <c>Sequence</c>/
|
||||
/// <c>Id</c>/<c>Iteration</c>/<c>DataSize</c> 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).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
71
src/AcDream.Core.Net/Transport/ReliableTransport.cs
Normal file
71
src/AcDream.Core.Net/Transport/ReliableTransport.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System.Buffers;
|
||||
using AcDream.Core.Net.Cryptography;
|
||||
|
||||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root for the session's reliable transport (campaign doc §4):
|
||||
/// one <see cref="TransportClock"/>, the outbound flow queue (N1), and the
|
||||
/// unconditional counters. The inbound sequence tracker joins in N2 and the
|
||||
/// <c>AckNakScheduler</c> in N3/N4 — N1 deliberately leaves ack behavior in
|
||||
/// <c>WorldSession</c> untouched.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Sweep"/> is the once-per-frame pump slice retail runs from
|
||||
/// <c>Client::UseTime @ 0x00411C40</c> →
|
||||
/// <c>PacketController::UseTime @ 0x005410D0</c>: advance the interval
|
||||
/// clock, serve pending retransmits, prune the acked cache. The session
|
||||
/// calls it at the end of <c>Tick()</c> AND inside the blocking handshake
|
||||
/// pump loops (landmine #8 — the EnterWorld flood precedes the first Tick),
|
||||
/// gated on transport negotiation (ACE's <c>Session.CheckState</c> discards
|
||||
/// early control traffic).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<byte>? pool = null)
|
||||
{
|
||||
Clock = clock ?? new TransportClock();
|
||||
Stats = new TransportStats();
|
||||
Outbound = new OutboundFlowQueue(
|
||||
outboundIsaac,
|
||||
sessionClientId,
|
||||
Clock,
|
||||
Stats,
|
||||
send,
|
||||
pool);
|
||||
Stats.CacheDepthSource = () => Outbound.CacheDepth;
|
||||
}
|
||||
|
||||
/// <summary>Last reliable sequence on the wire — the value unsequenced
|
||||
/// control packets (the reflex ack) borrow without incrementing.</summary>
|
||||
public uint HighestIdSent => Outbound.HighestIdSent;
|
||||
|
||||
/// <summary>
|
||||
/// One transport pump: interval clock forward, pending NAKed resends
|
||||
/// out, acked cache entries pruned. Pump order per retail
|
||||
/// <c>FlowQueue::Empty @ 0x00548A20</c> (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).
|
||||
/// </summary>
|
||||
public void Sweep()
|
||||
{
|
||||
Clock.Update();
|
||||
Outbound.TransmitPendingResends();
|
||||
}
|
||||
|
||||
/// <summary>Returns every rented cache buffer to the pool.</summary>
|
||||
public void Dispose() => Outbound.Dispose();
|
||||
}
|
||||
131
src/AcDream.Core.Net/Transport/SentPacketStore.cs
Normal file
131
src/AcDream.Core.Net/Transport/SentPacketStore.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
using System.Buffers;
|
||||
|
||||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// FIFO cache of sent reliable datagrams awaiting the server's cumulative
|
||||
/// ack, ported from retail's <c>SentPacketStore</c>:
|
||||
/// <c>AddSentPacket @ 0x0054AB00</c> appends to the intrusive FIFO list
|
||||
/// (<c>m_sentPacketList</c>, ctor/list plumbing @ 0x0054A8A0/0x0054A8D0);
|
||||
/// <c>Flush @ 0x0054ACD0</c> pops from the head while the entry's sequence
|
||||
/// is STRICTLY older than the watermark, wrap-safe (the inline
|
||||
/// <c>lhs_newer</c> arithmetic breaks the walk at <c>seqNum_ == arg2</c> 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Each entry keeps the COMPLETE wire buffer (20-byte header at
|
||||
/// <c>[0..20)</c>, body at <c>[20..20+BodyLength)</c>) in an
|
||||
/// ArrayPool-rented array, plus the two values a rebuilt resend header
|
||||
/// needs: the sealed checksum (retail <c>NetPacket::checksum_</c> — the
|
||||
/// pre-header-hash value) and the ISAAC key that sealed it (never redrawn —
|
||||
/// campaign landmine #2).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Reliable packets never carry optional headers under the campaign's
|
||||
/// standalone-control design (§4), which makes retail's
|
||||
/// <c>NetPacket::RemoveDisposableOptionalHeaders @ 0x00549510</c> strip a
|
||||
/// provable no-op — <see cref="Add"/> asserts it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SentPacketStore : IDisposable
|
||||
{
|
||||
/// <summary>One cached sent packet (campaign doc §4 cache entry).</summary>
|
||||
internal readonly struct CachedPacket(
|
||||
uint sequence,
|
||||
byte[] buffer,
|
||||
int bodyLength,
|
||||
uint sealedChecksum,
|
||||
uint isaacKey,
|
||||
bool hasFragments)
|
||||
{
|
||||
/// <summary>Packet sequence (header verbatim on resend).</summary>
|
||||
public uint Sequence { get; } = sequence;
|
||||
|
||||
/// <summary>Rented wire buffer: header [0..20), body [20..20+BodyLength).</summary>
|
||||
public byte[] Buffer { get; } = buffer;
|
||||
|
||||
/// <summary>Body byte count (header DataSize verbatim on resend).</summary>
|
||||
public int BodyLength { get; } = bodyLength;
|
||||
|
||||
/// <summary>Checksum value BEFORE the header hash is added:
|
||||
/// <c>isaacKey ^ payloadHash</c> for encrypted packets (retail
|
||||
/// <c>NetPacket::checksum_</c>).</summary>
|
||||
public uint SealedChecksum { get; } = sealedChecksum;
|
||||
|
||||
/// <summary>The outbound ISAAC word that sealed this packet. Kept for
|
||||
/// audit/diagnostics — a resend reuses <see cref="SealedChecksum"/>
|
||||
/// and NEVER draws a new word (<c>CryptoSystem::EncryptData @
|
||||
/// 0x0065FF40</c> non-null key path).</summary>
|
||||
public uint IsaacKey { get; } = isaacKey;
|
||||
|
||||
/// <summary>True when the body carries fragments — the rebuilt
|
||||
/// resend header ORs <c>BlobFragments</c> back in.</summary>
|
||||
public bool HasFragments { get; } = hasFragments;
|
||||
}
|
||||
|
||||
private readonly ArrayPool<byte> _pool;
|
||||
private readonly Queue<CachedPacket> _fifo = new();
|
||||
private readonly Dictionary<uint, CachedPacket> _bySequence = new();
|
||||
|
||||
public SentPacketStore(ArrayPool<byte>? pool = null) =>
|
||||
_pool = pool ?? ArrayPool<byte>.Shared;
|
||||
|
||||
public int Count => _fifo.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Cache one successfully sent reliable packet
|
||||
/// (<c>SentPacketStore::AddSentPacket @ 0x0054AB00</c>; the caller
|
||||
/// commits AFTER the send succeeds, <c>FlowQueue::TransmitNewPackets @
|
||||
/// 0x00547C85</c>). Takes ownership of <c>packet.Buffer</c>.
|
||||
/// </summary>
|
||||
/// <param name="optionalLength">
|
||||
/// Asserted zero: our reliable packets never carry optional headers, so
|
||||
/// retail's disposable-optional-header strip is a no-op (campaign §2.1).
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Pop from the FIFO head while the head is STRICTLY older than
|
||||
/// <paramref name="watermark"/>, wrap-safe — the watermark entry itself
|
||||
/// survives (<c>SentPacketStore::Flush @ 0x0054ACD0</c>). Returned
|
||||
/// buffers go back to the pool.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Return every rented buffer to the pool.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
while (_fifo.TryDequeue(out CachedPacket entry))
|
||||
_pool.Return(entry.Buffer);
|
||||
_bySequence.Clear();
|
||||
}
|
||||
}
|
||||
30
src/AcDream.Core.Net/Transport/SequenceMath.cs
Normal file
30
src/AcDream.Core.Net/Transport/SequenceMath.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Wrap-safe 32-bit transport sequence comparisons, ported from retail
|
||||
/// <c>TimeStampUtils::lhs_newer(uint32_t, uint32_t) @ 0x00543890</c>
|
||||
/// (named-retail pseudo-C :334086): compute the unsigned distance, flip the
|
||||
/// verdict when it exceeds <c>0x7FFFFFFF</c>. That is exactly the sign of the
|
||||
/// two's-complement difference, so <see cref="IsNewer"/> reduces to one
|
||||
/// signed comparison.
|
||||
///
|
||||
/// <para>
|
||||
/// Boundary note: at a distance of exactly <c>0x80000000</c> 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 —
|
||||
/// <c>AbnormalSequenceReceived</c>), so the campaign pins the simpler form
|
||||
/// (campaign doc §4).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class SequenceMath
|
||||
{
|
||||
/// <summary>True when <paramref name="a"/> is strictly newer than
|
||||
/// <paramref name="b"/> in wrap-safe sequence order.</summary>
|
||||
public static bool IsNewer(uint a, uint b) => unchecked((int)(a - b)) > 0;
|
||||
|
||||
/// <summary>The wrap-safe newer of two sequence values (either one when
|
||||
/// they are equal).</summary>
|
||||
public static uint Max(uint a, uint b) => IsNewer(a, b) ? a : b;
|
||||
}
|
||||
79
src/AcDream.Core.Net/Transport/TransportClock.cs
Normal file
79
src/AcDream.Core.Net/Transport/TransportClock.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// The transport's monotonic time authority: an injectable timestamp source
|
||||
/// (production default <see cref="Stopwatch.GetTimestamp"/>) driving retail's
|
||||
/// 0.5-second interval counter. <see cref="IntervalId"/> is the value retail
|
||||
/// writes into <c>ProtoHeader::interval_</c> — our
|
||||
/// <see cref="Packets.PacketHeader.Time"/> — on rebuilt resend headers.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>ClientFlowQueue::IncrementLocalInterval @ 0x00547F10</c>
|
||||
/// (named-retail pseudo-C :338389) — the tail is
|
||||
/// <c>CurLocalInterval_.intervalID_ += elapsedIntervals</c>, 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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: <see cref="Update"/> runs only from the
|
||||
/// session sweep.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class TransportClock
|
||||
{
|
||||
private readonly Func<long> _timestampSource;
|
||||
private readonly long _ticksPerInterval;
|
||||
private long _intervalBaseTimestamp;
|
||||
|
||||
/// <summary>Timestamp ticks per second of the injected source.</summary>
|
||||
public long Frequency { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Retail's 0.5-s interval counter (<c>CurLocalInterval_.intervalID_</c>).
|
||||
/// Starts at 1; wraps with natural ushort arithmetic.
|
||||
/// </summary>
|
||||
public ushort IntervalId { get; private set; }
|
||||
|
||||
public TransportClock(
|
||||
Func<long>? 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;
|
||||
}
|
||||
|
||||
/// <summary>Current raw timestamp from the injected source.</summary>
|
||||
public long GetTimestamp() => _timestampSource();
|
||||
|
||||
/// <summary>
|
||||
/// Advance <see cref="IntervalId"/> by however many whole 0.5-s
|
||||
/// intervals have elapsed since the last update. Called once per sweep.
|
||||
/// </summary>
|
||||
public void Update()
|
||||
{
|
||||
long elapsed = _timestampSource() - _intervalBaseTimestamp;
|
||||
if (elapsed < _ticksPerInterval)
|
||||
return;
|
||||
|
||||
long steps = elapsed / _ticksPerInterval;
|
||||
IntervalId = unchecked((ushort)(IntervalId + steps));
|
||||
_intervalBaseTimestamp += steps * _ticksPerInterval;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core.Net/Transport/TransportStats.cs
Normal file
38
src/AcDream.Core.Net/Transport/TransportStats.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>[net-tick]</c> 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.
|
||||
/// </summary>
|
||||
internal sealed class TransportStats
|
||||
{
|
||||
/// <summary>Datagrams re-emitted in response to a server NAK.</summary>
|
||||
public long ResendsSent;
|
||||
|
||||
/// <summary>Inbound packets carrying <c>RequestRetransmit</c>.</summary>
|
||||
public long NakRequestsReceived;
|
||||
|
||||
/// <summary>
|
||||
/// NAKed ids no longer (or never) in the sent-packet cache, dropped
|
||||
/// silently instead of answering retail's <c>RejectRetransmit</c>
|
||||
/// (divergence register TS-57 — ACE no-ops the reject, and the
|
||||
/// standalone unsequenced form would trip ACE's watermark hole).
|
||||
/// </summary>
|
||||
public long UncachedNakIds;
|
||||
|
||||
/// <summary>Inbound <c>AckSequence</c> values folded into the watermark
|
||||
/// (explicit acks plus the NAK <c>ids[0]</c> implicit ack).</summary>
|
||||
public long AcksConsumed;
|
||||
|
||||
/// <summary>Live sent-packet cache depth — the N5 watchdog value
|
||||
/// (<c>cache=N</c> in <c>[net-tick]</c>; the cache is unbounded like
|
||||
/// retail's, so depth is the health signal, not a cap).</summary>
|
||||
public int CacheDepth => CacheDepthSource?.Invoke() ?? 0;
|
||||
|
||||
/// <summary>Wired by <see cref="ReliableTransport"/> to the store's
|
||||
/// <c>Count</c>.</summary>
|
||||
internal Func<int>? CacheDepthSource { get; set; }
|
||||
}
|
||||
|
|
@ -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)
|
|||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Still deferred:</b> retransmit handling and unsolicited-disconnect
|
||||
/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout
|
||||
/// are live.
|
||||
/// <b>Still deferred:</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldSession : IDisposable
|
||||
|
|
@ -672,12 +674,22 @@ public sealed class WorldSession : IDisposable
|
|||
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
|
||||
|
||||
private IsaacRandom? _inboundIsaac;
|
||||
private IsaacRandom? _outboundIsaac;
|
||||
private ushort _sessionClientId;
|
||||
private ushort _sessionIteration;
|
||||
private bool _transportNegotiated;
|
||||
private uint _clientPacketSequence;
|
||||
private uint _fragmentSequence = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Connect"/>; null
|
||||
/// before negotiation (reliable sends are impossible then anyway — the
|
||||
/// keystream does not exist yet).
|
||||
/// </summary>
|
||||
private ReliableTransport? _transport;
|
||||
|
||||
/// <summary>Test seam: transport counters + cache depth for the
|
||||
/// conformance/loss suites. Null before negotiation.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1: one reliable-transport pump slice (retail
|
||||
/// <c>PacketController::UseTime @ 0x005410D0</c> shape): interval clock
|
||||
/// forward, pending NAKed resends out, acked cache pruned. Gated on
|
||||
/// negotiation — ACE's <c>Session.CheckState</c> silently discards
|
||||
/// pre-negotiation control traffic (campaign landmine #8), and the
|
||||
/// transport does not exist before the ISAAC seeds do.
|
||||
/// </summary>
|
||||
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<byte> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AceCryptoModel"/> with the ORIGINAL
|
||||
/// keystream word — campaign landmines #1/#2).
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(
|
||||
() => 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The #260 fix end-to-end: a REAL <see cref="WorldSession"/> 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.
|
||||
/// </summary>
|
||||
[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<byte[]>();
|
||||
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<byte[]> Sent)
|
||||
CreateQueue()
|
||||
{
|
||||
var virtualClock = new VirtualClock();
|
||||
var clock = new TransportClock(
|
||||
virtualClock.GetTimestamp,
|
||||
virtualClock.Frequency);
|
||||
var stats = new TransportStats();
|
||||
var sent = new List<byte[]>();
|
||||
var queue = new OutboundFlowQueue(
|
||||
MakeIsaac(ClientSeed),
|
||||
(ushort)ClientId,
|
||||
clock,
|
||||
stats,
|
||||
datagram => sent.Add(datagram.ToArray()));
|
||||
return (queue, virtualClock, clock, stats, sent);
|
||||
}
|
||||
|
||||
/// <summary>Deliver a NAK id list the way ProcessDatagram does: raw
|
||||
/// little-endian u32 ids + count.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>An 8-byte message body whose first byte is a test marker.</summary>
|
||||
private static byte[] MakeMessage(byte marker) =>
|
||||
new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 };
|
||||
|
||||
private static IsaacRandom MakeIsaac(uint seed)
|
||||
{
|
||||
Span<byte> seedBytes = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
|
||||
return new IsaacRandom(seedBytes);
|
||||
}
|
||||
|
||||
/// <summary>A negotiated ACE double, matching the AceSessionModelTests
|
||||
/// fixture: LoginRequest → ConnectRequest (discarded) → ConnectResponse
|
||||
/// → immediate first TimeSync (discarded; S2C seq 2).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recover the ISAAC word from an encrypted datagram's checksum:
|
||||
/// key = (checksum − headerHash) ^ payloadHash (ClientPacket.cs:142).
|
||||
/// </summary>
|
||||
private static uint ExtractIsaacKey(byte[] datagram)
|
||||
{
|
||||
PacketHeader header = PacketHeader.Unpack(datagram);
|
||||
ReadOnlySpan<byte> 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<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>A cache entry whose buffer is rented from
|
||||
/// <paramref name="pool"/>, so rent/return balance is assertable.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>ArrayPool wrapper counting rents/returns for balance
|
||||
/// assertions.</summary>
|
||||
private sealed class CountingPool : ArrayPool<byte>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue