From 0265cc423614b14c759ea292bfa3fa5924c612a7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 13:51:57 +0200 Subject: [PATCH] feat(net): N3 - AckNakScheduler, retail 2.0s cumulative ack replaces per-packet acks Campaign N slice N3. Retail never acks per packet: SharedNet::EnqueuePak @ 0x00543B10 is the binary's only AckSequence (0x4000) construction site, gated at >= 2.0 s on ReceiverData::timeStamp_ (@ +0x10), armed at connection birth by ReceiverData::Init @ 0x00548EF0, and arbitrated NAK-xor-ack per sweep by ClientNet::ProcessConnection @ 0x00545450 (m_SeqIDsWeNAKed non-empty -> EnqueueNaks, else EnqueuePak; SharedNet::EnqueueNaks @ 0x00543BD0 shares the SAME timestamp - campaign landmine #7). - New Transport/AckNakScheduler: owns the one shared timestamp; a non-empty NAK set suppresses the ack (N4 emits RequestRetransmit in that branch; in N3 it emits nothing - a documented transitional state, safe for exactly one slice on loopback), else ONE cleartext exact-flags AckSequence carrying the tracker's HighestIdReceived, header sequence borrowed from HighestIdSent without incrementing, 4-byte LE body. Flags are an EQUALITY, never an OR (landmine #5 - ACE's dedup exemption NetworkSession.cs:342-343 and watermark-skip :474-476 both require the exact value). - ReliableTransport.Sweep pump order per FlowQueue::Empty @ 0x00548A20: interval clock, NAK/ack arbitration, pending resends, prune. The sweep already runs in Tick and both handshake pump loops (landmine #8), so cumulative acks flow during the character-list/enter-world floods at ACE's own ~2 s cadence. - WorldSession: the Phase 4.9 per-packet reflex ack in ProcessDatagram and SendAck are DELETED; the [net-tick] acks/s probe now reads Stats.AcksSent; new internal TransportClockSource seam drives the 2.0 s gate on virtual time in the conformance suite. - N1 Fable-review advisory retired (Time-stamp fold-in): fresh reliable sends now stamp Header.Time = the current interval id, matching retail FlowQueue::TransmitNewPackets @ 0x00547A60 (header build at 0x00547A84); resends already re-stamped. ACE never reads inbound Header.Time, so the wire stays compatible. Tests: 723 Core.Net (7 new) - gate cadence + watermark-at-emission, flags-equality pin + model acceptance at the reused sequence without a watermark advance, NAK suppression and resume after the gap clears, a 50-packet CreateObject flood collapsing to ONE ack, the quiet-session keepalive property across a 120 s virtual horizon (the reflex ack's keepalive role, replaced and proven against ACE's 60 s TimeoutDeadline), the Time fold-in, and a full FakeAceTransport lifecycle with zero CRC/state/duplicate drops. Full solution Release: 9,744 passed / 5 skipped / 0 failed. Connected world-lifecycle gate PASS (capped + uncapped-reconnect, graceful exits, 0 failures); canonical nine-stop route PASS (0 failures). Campaign section 9 N3 row updated (complete; SHA recorded at N4 kickoff). Co-Authored-By: Claude Sonnet 5 --- .../2026-07-29-network-transport-campaign.md | 2 +- .../Transport/AckNakScheduler.cs | 168 +++++++ .../Transport/OutboundFlowQueue.cs | 11 +- .../Transport/ReliableTransport.cs | 46 +- .../Transport/TransportStats.cs | 5 + src/AcDream.Core.Net/WorldSession.cs | 128 ++--- .../Transport/AckNakSchedulerTests.cs | 461 ++++++++++++++++++ .../OutboundReliableTransportTests.cs | 37 +- ...rldSessionNetReceiveLoopResilienceTests.cs | 66 ++- 9 files changed, 797 insertions(+), 127 deletions(-) create mode 100644 src/AcDream.Core.Net/Transport/AckNakScheduler.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs diff --git a/docs/plans/2026-07-29-network-transport-campaign.md b/docs/plans/2026-07-29-network-transport-campaign.md index a181ee4d..5a59b7ff 100644 --- a/docs/plans/2026-07-29-network-transport-campaign.md +++ b/docs/plans/2026-07-29-network-transport-campaign.md @@ -254,7 +254,7 @@ verbatim in each implementer prompt. | N0 | complete | `7e9134b4` + `e3958610` | ACE-behaviour double + virtual clock + lossy link; the review fix-up added the `Session.CheckState` inbound gate, faithful `SendBundle` coalescing/splitting, two-phase termination, an ACE-loose C2S fragment parse, and ACE's MessageBuffer edge cases. 687 Core.Net tests green. N1 folded in the re-review's `ProcessFragment` two-branch split (existing-buffer checks Complete; new-buffer parks without checking — the zero-count buffer stays parked). | | N1 | complete | `43e60a69` | Outbound sent-packet cache + resend on NAK (`Transport/`: `TransportClock`, `SequenceMath`, `SentPacketStore`, `OutboundFlowQueue`, `ReliableTransport`, `TransportStats`); `PacketCodec.FinalizeInPlace` sealed-checksum overload; `WorldSession` sweep in Tick + both handshake pump loops; TS-57 filed, TS-27 narrowed to inbound-only. **Fable review PASS.** Advisories: fresh sends keep `Time=0` (byte-identical wire; retail stamps the interval on every packet and ACE ignores the field — N3 folds the retail stamp in with the cadence work); the stale-NAK single-redundant-resend window is shared with retail (pending prunes at flush, after transmit — same frame order as `RecipientData::UseTime`). | | N2 | complete | `46d209d0` — **Fable review PASS** (tracker/codec-split/admission verified pre-commit against retail rules; gates lifecycle+nine-stop both PASS) | Inbound sequence-aligned ISAAC + NAK set (`Transport/InboundSequenceTracker`: watermark, sanity window, duplicate/parked-key path, sequence-ordered gap-walk pre-draw, verify-failure re-park, RejectRetransmit abandonment — `ProcessNewSeqNum @ 0x00544690`, `ProcessNewestSeqNum @ 0x00541930`, `SeqIDSanityCheck @ 0x00543A20`, `AddNakked @ 0x00549240`, `HandleEmptyAck @ 0x005448F0`); `PacketCodec` split into keystream-free `TryParseBorrowed` + `VerifyChecksum` (`TryDecodeBorrowed` deleted); `RejectRetransmit` ids exposed on both optional-header decoders; `ReliableTransport` now owns both keystreams; stats gained `InboundDupsDropped`/`InboundSanityDrops`/`ChecksumFailures`/`KeysParked`. Watermark init 1 is the AD-50 ACE adaptation (retail zero-init vs ACE's re-prime dance — first encrypted S2C is sequence 2; holtburger api.rs:30 agrees); pinned by the clean-lifecycle conformance test (zero NAKs, min encrypted S2C sequence == 2). 716 Core.Net tests green. **N3/N4 handoff note:** the interim per-packet reflex ack acks the ARRIVING sequence even while a gap is parked, so ACE prunes the lost id from its S2C cache (`AcknowledgeSequence` strictly-below) before N4 can NAK it — message-level recovery of a real loss needs N3's retail NAK-xor-ack sweep (§2.3's mutual exclusivity is load-bearing). Also noted for N4: ACE's `RejectRetransmit` consumes a fresh CLEARTEXT sequence via FlushPackets (no keystream word), so the client's gap walk parks a word for an id that never had one server-side — a real retail-vs-ACE incompatibility to resolve in N4's design (retail never assigns new sequences to cleartext). | -| N3 | pending | — | | +| N3 | complete | — (SHA recorded at N4 kickoff) | AckNakScheduler + 2.0 s cumulative ack (`Transport/AckNakScheduler`): ONE shared timestamp (`ReceiverData::timeStamp_` @ +0x10) arbitrating NAK-xor-ack per sweep (`ClientNet::ProcessConnection @ 0x00545450`); the ack is one cleartext exact-flags `AckSequence` carrying the tracker's `highestIDReceived_` behind the >= 2.0 s gate (`SharedNet::EnqueuePak @ 0x00543B10` — the binary's only 0x4000 construction site), armed at connection birth (`ReceiverData::Init @ 0x00548EF0`). The Phase 4.9 per-packet reflex ack and `WorldSession.SendAck` are DELETED; the `[net-tick]` acks/s probe now reads `Stats.AcksSent`. Sweep order per `FlowQueue::Empty @ 0x00548A20`: interval clock, NAK/ack arbitration, pending resends, prune. **N3 transitional state:** a non-empty NAK set suppresses the ack and emits NOTHING (no NAK yet) — safe for exactly one slice on loopback; N4 completes the branch. **N1 advisory retired (fold-in):** fresh reliable sends now stamp `Header.Time` = the current interval id (`FlowQueue::TransmitNewPackets @ 0x00547A60`, header build at 0x00547A84); ACE ignores inbound `Header.Time`, so the wire is unaffected. New `WorldSession.TransportClockSource` seam drives the gate on virtual time. 723 Core.Net tests green (keepalive property proven: a quiet session's 2 s acks refresh ACE's 60 s deadline across a 120 s virtual horizon; storm collapse: a 50-packet flood → ONE ack; the model accepts the reused-sequence ack without advancing its watermark). Connected lifecycle + canonical nine-stop gates PASS. | | N4 | pending | — | | | N5 | pending | — | | | N6 | pending | — | | diff --git a/src/AcDream.Core.Net/Transport/AckNakScheduler.cs b/src/AcDream.Core.Net/Transport/AckNakScheduler.cs new file mode 100644 index 00000000..34480588 --- /dev/null +++ b/src/AcDream.Core.Net/Transport/AckNakScheduler.cs @@ -0,0 +1,168 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Transport; + +/// +/// Campaign N Slice N3: retail's per-frame ack/NAK arbitration +/// (ClientNet::ProcessConnection @ 0x00545450), replacing the +/// Phase 4.9 per-packet reflex ack. Retail never acks per packet — +/// SharedNet::EnqueuePak @ 0x00543B10 is the ONLY +/// AckSequence (0x4000) construction site in the whole binary, and +/// it fires from this sweep alone. +/// +/// +/// The two branches are mutually exclusive on ONE shared timestamp +/// (ReceiverData::timeStamp_ @ +0x10 — campaign landmine #7): +/// +/// +/// NAK set non-empty → the NAK branch +/// (SharedNet::EnqueueNaks @ 0x00543BD0, 0.6 s gate on the SAME +/// timestamp) and NO ack this sweep. N4 emits the +/// RequestRetransmit here; in N3 the branch exists but emits +/// NOTHING — parked NAKs mean neither ack nor NAK goes out. That +/// transitional state is safe for exactly one slice: on loopback gates +/// nothing creates stray parked ids, and with no NAK emission ACE never +/// produces the fresh-cleartext RejectRetransmit wrinkle the N2 ledger +/// row records. N4 completes the branch. +/// Else, when now − sharedTimestamp ≥ 2.0 s: ONE cumulative +/// AckSequence carrying the tracker's highestIDReceived_ +/// (SharedNet::EnqueuePak @ 0x00543B10), then +/// sharedTimestamp = now. +/// +/// +/// +/// The gate is armed at construction: retail's ReceiverData::Init +/// (@ 0x00548EF0, the timeStamp_ = Timer::cur_time store at +/// 0x00548F46) stamps the shared timestamp at connection birth, so the +/// first cumulative ack goes out 2.0 s after negotiation — the same shape +/// as ACE's own sendAck arming (NetworkSession.cs:54-55). Do NOT +/// shorten the gate: ACE's S2C cache holds 120 s and ACE's own ack cadence +/// is the same 2 s. +/// +/// +/// +/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125): +/// flags EXACTLY — an equality, +/// never an OR (landmine #5: ACE's dedup exemption at NetworkSession.cs:342-343 +/// and the watermark-skip at :474-476 both require the exact value) — +/// cleartext (no ISAAC word), 4-byte little-endian body, header +/// Sequence borrowed from highestIDSent_ without incrementing, +/// Id = the session client id, Time/Iteration zero. +/// +/// +/// +/// Single-threaded like the rest of the transport: +/// runs only from on the session's +/// frame thread. +/// +/// +internal sealed class AckNakScheduler +{ + /// Retail's cumulative-ack gate + /// (SharedNet::EnqueuePak @ 0x00543B10, the x87 compare against + /// 2.0 at 0x00543B32). + public const double AckGateSeconds = 2.0; + + private readonly InboundSequenceTracker _inbound; + private readonly OutboundFlowQueue _outbound; + private readonly TransportStats _stats; + private readonly DatagramSendDelegate _send; + private readonly ushort _sessionClientId; + private readonly long _ackGateTicks; + + /// THE shared timestamp (ReceiverData::timeStamp_ + /// @ +0x10). N4's 0.6 s NAK gate reads and writes this exact field — + /// never introduce a second timestamp (landmine #7): a NAK delays the + /// next ack and vice versa. + private long _sharedTimestamp; + + public AckNakScheduler( + TransportClock clock, + InboundSequenceTracker inbound, + OutboundFlowQueue outbound, + ushort sessionClientId, + TransportStats stats, + DatagramSendDelegate send) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(inbound); + ArgumentNullException.ThrowIfNull(outbound); + ArgumentNullException.ThrowIfNull(stats); + ArgumentNullException.ThrowIfNull(send); + + _inbound = inbound; + _outbound = outbound; + _sessionClientId = sessionClientId; + _stats = stats; + _send = send; + _ackGateTicks = (long)(AckGateSeconds * clock.Frequency); + // ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time at + // connection birth: the gate starts armed, first ack at +2.0 s. + _sharedTimestamp = clock.GetTimestamp(); + } + + /// + /// One per-frame arbitration pass (ClientNet::ProcessConnection + /// @ 0x00545450: m_SeqIDsWeNAKed._currNum != 0 ? EnqueueNaks + /// : EnqueuePak). is the transport clock's + /// current timestamp, sampled once by the caller. + /// + public void Sweep(long now) + { + if (_inbound.NakCount > 0) + { + // The NAK branch (SharedNet::EnqueueNaks @ 0x00543BD0, 0.6 s + // gate on _sharedTimestamp). N4 emits the RequestRetransmit + // here; until then parked NAKs suppress the ack and nothing + // goes out this sweep — see the class doc for why that + // transitional state is safe for exactly this slice. The + // timestamp is NOT touched: only an actual emission stamps it. + return; + } + + // SharedNet::EnqueuePak @ 0x00543B10 — proceed when the elapsed + // time is NOT less than 2.0 (the `& 1` x87 status test at + // 0x00543B3D), i.e. now − sharedTimestamp ≥ 2.0 s. + if (now - _sharedTimestamp < _ackGateTicks) + return; + + EmitCumulativeAck(); + _sharedTimestamp = now; + } + + /// + /// Build and send the one cumulative AckSequence + /// (SharedNet::EnqueuePak @ 0x00543B10: mask 0x4000, 4-byte + /// payload = highestIDReceived_), byte-shaped exactly like the + /// pre-N3 reflex ack except the VALUE is the cumulative watermark. + /// + private void EmitCumulativeAck() + { + Span datagram = stackalloc byte[PacketHeader.Size + sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian( + datagram.Slice(PacketHeader.Size), + _inbound.HighestIdReceived); + + var header = new PacketHeader + { + // Borrow the last reliable sequence without incrementing — + // the ack is not part of the reliable stream. ACE accepts it + // at the reused sequence via the exact-flags dedup exemption + // (NetworkSession.cs:342-343) and skips the watermark advance + // (:474-476). + Sequence = _outbound.HighestIdSent, + Flags = PacketHeaderFlags.AckSequence, + Id = _sessionClientId, + }; + + int datagramLength = PacketCodec.FinalizeInPlace( + header, + datagram, + bodyLength: sizeof(uint), + optionalLength: sizeof(uint), + outboundIsaac: null); + _send(datagram.Slice(0, datagramLength)); + _stats.AcksSent++; + } +} diff --git a/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs b/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs index 8f9a3281..97fbe26f 100644 --- a/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs +++ b/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs @@ -117,9 +117,13 @@ internal sealed class OutboundFlowQueue : IDisposable /// Encode one game message as a single-fragment reliable packet, send /// it, THEN cache it (retail commits to the sent-packet store only after /// a successful send — FlowQueue::TransmitNewPackets @ 0x00547C85). - /// Wire shape is byte-identical to the pre-N1 WorldSession path: - /// flags BlobFragments|EncryptedChecksum, Time/Iteration - /// zero, session client id, one ISAAC word. + /// Flags BlobFragments|EncryptedChecksum, session client id, one + /// ISAAC word, and — the N3 fold-in of the N1 review advisory — + /// Time = the current interval id: retail stamps + /// CurLocalInterval_.intervalID_ on every fresh packet + /// (FlowQueue::TransmitNewPackets @ 0x00547A60, the header build + /// at 0x00547A84). ACE never reads inbound Header.Time + /// (campaign §3), so wire compatibility is unaffected. /// public void SendGameMessage( ReadOnlySpan gameMessageBody, @@ -142,6 +146,7 @@ internal sealed class OutboundFlowQueue : IDisposable Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, Id = _sessionClientId, + Time = _clock.IntervalId, }; int datagramLength = PacketCodec.FinalizeInPlace( header, diff --git a/src/AcDream.Core.Net/Transport/ReliableTransport.cs b/src/AcDream.Core.Net/Transport/ReliableTransport.cs index b323f99d..decfffaa 100644 --- a/src/AcDream.Core.Net/Transport/ReliableTransport.cs +++ b/src/AcDream.Core.Net/Transport/ReliableTransport.cs @@ -6,19 +6,20 @@ namespace AcDream.Core.Net.Transport; /// /// Composition root for the session's reliable transport (campaign doc §4): /// one , the outbound flow queue (N1), the -/// inbound sequence tracker (N2), and the unconditional counters. The -/// AckNakScheduler joins in N3/N4 — until then ack behavior stays in -/// WorldSession untouched. +/// inbound sequence tracker (N2), the ack/NAK scheduler (N3), and the +/// unconditional counters. /// /// /// is the once-per-frame pump slice retail runs from /// Client::UseTime @ 0x00411C40 → /// PacketController::UseTime @ 0x005410D0: advance the interval -/// clock, serve pending retransmits, prune the acked cache. The session -/// calls it at the end of Tick() AND inside the blocking handshake -/// pump loops (landmine #8 — the EnterWorld flood precedes the first Tick), -/// gated on transport negotiation (ACE's Session.CheckState discards -/// early control traffic). +/// clock, arbitrate NAK-xor-ack, serve pending retransmits, prune the acked +/// cache. The session calls it at the end of Tick() AND inside the +/// blocking handshake pump loops (landmine #8 — the EnterWorld flood +/// precedes the first Tick; ACE needs acks during the character-list / +/// enter-world floods, and the scheduler's ~2 s cadence there matches ACE's +/// own), gated on transport negotiation (ACE's Session.CheckState +/// discards early control traffic). /// /// internal sealed class ReliableTransport : IDisposable @@ -32,6 +33,11 @@ internal sealed class ReliableTransport : IDisposable /// queue at ISAAC-seeding time so both keystreams share one owner. public InboundSequenceTracker Inbound { get; } + /// N3: retail's NAK-xor-ack sweep arbitration on the one + /// shared timestamp (ClientNet::ProcessConnection @ 0x00545450); + /// owns the 2.0 s cumulative AckSequence. + public AckNakScheduler Scheduler { get; } + public TransportStats Stats { get; } public ReliableTransport( @@ -52,24 +58,34 @@ internal sealed class ReliableTransport : IDisposable send, pool); Inbound = new InboundSequenceTracker(inboundIsaac, Stats); + Scheduler = new AckNakScheduler( + Clock, + Inbound, + Outbound, + sessionClientId, + Stats, + send); Stats.CacheDepthSource = () => Outbound.CacheDepth; } /// Last reliable sequence on the wire — the value unsequenced - /// control packets (the reflex ack) borrow without incrementing. + /// control packets (the cumulative ack) borrow without incrementing. public uint HighestIdSent => Outbound.HighestIdSent; /// - /// One transport pump: interval clock forward, pending NAKed resends - /// out, acked cache entries pruned. Pump order per retail - /// FlowQueue::Empty @ 0x00548A20 (NAK consumption already - /// happened at receive time; retransmits precede new packets — new - /// packets are sent synchronously by the session, so the sweep runs - /// before the frame's sends the same way retail's per-frame pump does). + /// One transport pump: interval clock forward, NAK-xor-ack arbitration, + /// pending NAKed resends out, acked cache entries pruned. Pump order per + /// retail FlowQueue::Empty @ 0x00548A20: the interval clock, then + /// the control-packet arbitration (ClientNet::ProcessConnection + /// @ 0x00545450 enqueues NAKs-or-ack before the flow queue drains), + /// then retransmits, then new packets — new packets are sent + /// synchronously by the session, so the sweep runs before the frame's + /// sends the same way retail's per-frame pump does. /// public void Sweep() { Clock.Update(); + Scheduler.Sweep(Clock.GetTimestamp()); Outbound.TransmitPendingResends(); } diff --git a/src/AcDream.Core.Net/Transport/TransportStats.cs b/src/AcDream.Core.Net/Transport/TransportStats.cs index 6e3eb639..3c06ea21 100644 --- a/src/AcDream.Core.Net/Transport/TransportStats.cs +++ b/src/AcDream.Core.Net/Transport/TransportStats.cs @@ -47,6 +47,11 @@ internal sealed class TransportStats /// pre-draw) plus one per checksum-failure re-park. public long KeysParked; + /// N3: cumulative AckSequence packets emitted by the + /// 2.0 s sweep (SharedNet::EnqueuePak @ 0x00543B10 — retail's + /// only ack construction site; there is no per-packet ack). + public long AcksSent; + /// Live sent-packet cache depth — the N5 watchdog value /// (cache=N in [net-tick]; the cache is unbounded like /// retail's, so depth is the health signal, not a cap). diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index c8a82045..bac7ae52 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -690,6 +690,16 @@ public sealed class WorldSession : IDisposable /// conformance/loss suites. Null before negotiation. internal ReliableTransport? Transport => _transport; + /// + /// N3 test seam: injectable monotonic source for the transport clock so + /// the conformance suite can drive the 2.0 s cumulative-ack gate (and + /// the 0.5 s interval counter) on virtual time. Must be set BEFORE + /// (the transport is born there). Null → + /// production timing. + /// + internal (Func GetTimestamp, long Frequency)? TransportClockSource + { get; set; } + // Movement sequence counters — echoed back in every MoveToState and // AutonomousPosition so the server can detect stale/reordered packets. // Initialized from CreateObject PhysicsData timestamps, updated by @@ -890,19 +900,26 @@ public sealed class WorldSession : IDisposable // generation into connection-level control packets, including the // final disconnect. ACE currently emits iteration 1. _sessionIteration = connectRequestIteration; - // N1+N2: the reliable transport is born at ISAAC-seeding time, - // owning BOTH keystreams. Outbound: highestIDSent starts 1 (the - // ConnectResponse below carries sequence 1), so the first reliable - // packet after the handshake keeps packet sequence 2 and fragment - // sequence 1 — byte-identical to the pre-N1 wire behavior. Inbound: - // the tracker owns the server keystream, the received watermark, - // and the NAK set (campaign §2.2); its watermark starts 1 (see - // InboundSequenceTracker.AceInitialWatermark). + // N1+N2+N3: the reliable transport is born at ISAAC-seeding time, + // owning BOTH keystreams and the ack/NAK sweep. Outbound: + // highestIDSent starts 1 (the ConnectResponse below carries + // sequence 1), so the first reliable packet after the handshake + // keeps packet sequence 2 and fragment sequence 1. Inbound: the + // tracker owns the server keystream, the received watermark, and + // the NAK set (campaign §2.2); its watermark starts 1 (see + // InboundSequenceTracker.AceInitialWatermark). The scheduler's + // 2.0 s cumulative-ack gate arms here, at connection birth + // (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time). _transport = new ReliableTransport( new IsaacRandom(clientSeedBytes), new IsaacRandom(serverSeedBytes), _sessionClientId, - datagram => _net.Send(datagram)); + datagram => _net.Send(datagram), + clock: TransportClockSource is { } clockSource + ? new TransportClock( + clockSource.GetTimestamp, + clockSource.Frequency) + : null); _transportNegotiated = true; // Publish only after the receiver identity and crypto state are fully @@ -1090,16 +1107,18 @@ public sealed class WorldSession : IDisposable // #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 - // directly as maxgap. _probeSendWindow/_probeAckWindow are Interlocked so - // a hypothetical off-thread send can't corrupt the window counters (the - // [net-out] tid field is what would prove such a send exists). + // directly as maxgap. _probeSendWindow is Interlocked so a hypothetical + // off-thread send can't corrupt the window counter (the [net-out] tid + // field is what would prove such a send exists). Acks are counted by the + // transport (Stats.AcksSent, incremented on the frame-thread sweep); + // _probeAckSeenTotal is the last cumulative value the probe printed. private long _probeLastTickTs; private long _probeWindowStartTs; private long _probeMaxGapTicks; private int _probeProcessedWindow; private int _probeBudgetBreaks; private int _probeSendWindow; - private int _probeAckWindow; + private long _probeAckSeenTotal; // Probe-owned queue depth: the SingleReader channel's Reader.Count // throws NotSupportedException, so the net thread increments on // enqueue and the frame thread decrements on dequeue instead. @@ -1134,7 +1153,9 @@ public sealed class WorldSession : IDisposable double windowSeconds = (double)windowTicks / Stopwatch.Frequency; double maxGapMs = _probeMaxGapTicks * 1000.0 / Stopwatch.Frequency; int sends = Interlocked.Exchange(ref _probeSendWindow, 0); - int acks = Interlocked.Exchange(ref _probeAckWindow, 0); + long ackTotal = _transport?.Stats.AcksSent ?? 0; + long acks = ackTotal - _probeAckSeenTotal; + _probeAckSeenTotal = ackTotal; Console.WriteLine( $"[net-tick] in/s={_probeProcessedWindow / windowSeconds:F0}" + $" q={Volatile.Read(ref _probeInboundDepth)}" @@ -1391,9 +1412,12 @@ public sealed class WorldSession : IDisposable // acceptance, before any heavy render-thread message handling. Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp()); - // N1: consume the transport control surfaces FIRST, before the - // reflex ack below (which still fires unchanged this slice; the - // AckNakScheduler replaces it in N3). + // N1: consume the transport control surfaces. Acknowledging the + // OTHER direction is not done here: N3 deleted the Phase 4.9 + // per-packet reflex ack — retail never acks per packet + // (SharedNet::EnqueuePak @ 0x00543B10 is the binary's only 0x4000 + // construction site). The AckNakScheduler emits ONE cumulative + // AckSequence per 2.0 s from the SweepTransport pump instead. if (_transport is { } transport) { // Server NAK (RequestRetransmit 0x1000): merge the requested @@ -1426,19 +1450,6 @@ public sealed class WorldSession : IDisposable transport.Outbound.OnAckSequence(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 - // ack queued back; not periodic). Without it, ACE drops the session - // 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). - if (serverHeader.Sequence > 0 - && (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0) - { - SendAck(serverHeader.Sequence); - } - // Phase G.1: propagate TimeSync-flagged server time to anyone who // needs it (sky/day-night lerp in particular). Server sends this // periodically — no explicit opcode, just the header flag. @@ -2469,63 +2480,6 @@ public sealed class WorldSession : IDisposable return false; } - /// - /// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging - /// . This is a cleartext control - /// packet (no EncryptedChecksum) — the body is just the 4-byte server - /// sequence number being acknowledged. The header re-uses the most - /// recently sent client sequence (no increment) because acks aren't - /// themselves part of the reliable stream the server tracks. - /// - /// - /// Without sending these, ACE drops the session with - /// Network Timeout after ~60s — and during that 60s the - /// character appears to other clients as a stationary purple haze - /// (loading state) because the server hasn't seen the client confirm - /// any post-EnterWorld traffic. - /// - /// - /// - /// Pattern ported from - /// references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack - /// and the receive-side trigger at - /// .../session/receive.rs::finalize_ordered_server_packet. - /// - /// - private void SendAck(uint serverPacketSequence) - { - // 4-byte body: little-endian u32 of the server sequence we're acking. - Span datagram = stackalloc byte[ - PacketHeader.Size + sizeof(uint)]; - BinaryPrimitives.WriteUInt32LittleEndian( - datagram.Slice(PacketHeader.Size), - serverPacketSequence); - - // Holtburger uses current_client_sequence (= packet_sequence - 1) for - // ack headers. We mirror that — acks borrow the most recently issued - // 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 - { - Sequence = ackHeaderSequence, - Flags = PacketHeaderFlags.AckSequence, - Id = _sessionClientId, - }; - - int datagramLength = PacketCodec.FinalizeInPlace( - header, - datagram, - bodyLength: sizeof(uint), - optionalLength: sizeof(uint), - outboundIsaac: null); - _net.Send(datagram.Slice(0, datagramLength)); - if (NetDiagnostics.ProbeNet) - Interlocked.Increment(ref _probeAckWindow); - } - private void Transition(State next) { if (CurrentState == next) return; diff --git a/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs b/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs new file mode 100644 index 00000000..f512a2dd --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs @@ -0,0 +1,461 @@ +using System.Buffers.Binary; +using System.Net; +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; + +namespace AcDream.Core.Net.Tests.Transport; + +/// +/// Campaign N Slice N3 — the AckNakScheduler: retail's 2.0 s cumulative +/// AckSequence (SharedNet::EnqueuePak @ 0x00543B10, the only +/// 0x4000 construction site in the binary) replacing the Phase 4.9 +/// per-packet reflex ack, arbitrated NAK-xor-ack on ONE shared timestamp +/// (ClientNet::ProcessConnection @ 0x00545450, +/// ReceiverData::timeStamp_ — campaign landmine #7). +/// +public sealed class AckNakSchedulerTests +{ + private const uint ClientSeed = 0x11AA22BBu; + private const uint ServerSeed = 0x33CC44DDu; + private const uint ClientId = 0x1234u; + private const ulong Cookie = 0xFEEDFACECAFEBABEUL; + + // ===================================================================== + // The 2.0 s gate — SharedNet::EnqueuePak @ 0x00543B10 + // ===================================================================== + + [Fact] + public void CumulativeAck_TwoSecondGate_CarriesWatermarkAtEmission() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + Admit(transport, 2u); + Admit(transport, 3u); + + // No ack before 2.0 s — the gate armed at transport construction + // (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time). + transport.Sweep(); + clock.Advance(TimeSpan.FromSeconds(1.99)); + transport.Sweep(); + Assert.Empty(sent); + + // Exactly one at the boundary (the retail compare is >=, the x87 + // `& 1` status test at 0x00543B3D). + clock.Advance(TimeSpan.FromSeconds(0.01)); + transport.Sweep(); + AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 3u); + Assert.Equal(1, transport.Stats.AcksSent); + + // The gate reset: silent until the next 2.0 s elapses. + transport.Sweep(); + clock.Advance(TimeSpan.FromSeconds(1.99)); + transport.Sweep(); + Assert.Single(sent); + + // The watermark advanced between gates: the NEWER value rides — + // the ack always carries highestIDReceived_ AT EMISSION. + Admit(transport, 4u); + Admit(transport, 5u); + clock.Advance(TimeSpan.FromSeconds(0.01)); + transport.Sweep(); + Assert.Equal(2, sent.Count); + AssertAckShape(sent[1], expectedSequence: 1u, expectedValue: 5u); + Assert.Equal(2, transport.Stats.AcksSent); + } + + // ===================================================================== + // Flags equality (landmine #5) — model-side acceptance + // ===================================================================== + + /// + /// ACE's dedup exemption (NetworkSession.cs:342-343) and watermark-skip + /// (:474-476) both require Flags == AckSequence EXACTLY. The + /// emitted ack must be accepted at the reused client sequence WITHOUT + /// advancing ACE's watermark — any extra ORed bit would advance the + /// watermark past a live sequence and wedge the session (§3 row 3). + /// + [Fact] + public void ModelAcceptsAck_AtReusedSequence_WithoutAdvancingWatermark() + { + (AceSessionModel model, _) = CreateNegotiatedModel(); + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + + // Two reliable client packets reach ACE: sequences 2, 3. + transport.Outbound.SendGameMessage( + MakeMessage(1), GameMessageGroup.UIQueue); + transport.Outbound.SendGameMessage( + MakeMessage(2), GameMessageGroup.UIQueue); + foreach (byte[] datagram in sent) + model.Receive(datagram); + Assert.Equal(3u, model.LastReceivedPacketSequence); + sent.Clear(); + + // The sweep's cumulative ack borrows sequence 3 — the last issued + // client sequence, not a fresh one. + clock.Advance(TimeSpan.FromSeconds(2)); + transport.Sweep(); + byte[] ack = Assert.Single(sent); + PacketHeader ackHeader = PacketHeader.Unpack(ack); + Assert.Equal(3u, ackHeader.Sequence); + Assert.Equal( + (uint)PacketHeaderFlags.AckSequence, + (uint)ackHeader.Flags); + + model.Receive(ack); + Assert.Equal(0, model.DuplicateDropCount); + Assert.Equal(0, model.CrcDropCount); + Assert.Equal(0, model.StateDropCount); + // The watermark did NOT advance (:474-476 — exact-flags skip). + Assert.Equal(3u, model.LastReceivedPacketSequence); + } + + // ===================================================================== + // NAK-xor-ack mutual exclusivity (landmine #7) — + // ClientNet::ProcessConnection @ 0x00545450 + // ===================================================================== + + [Fact] + public void ParkedNak_SuppressesTheAck_AckResumesWhenTheGapClears() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + Admit(transport, 2u); // watermark 2, no gap + Admit(transport, 4u); // gap walk parks id 3 + Assert.Equal(1, transport.Inbound.NakCount); + + // 2.0 s elapses with the NAK set non-empty: the NAK branch owns the + // sweep and (until N4 emits RequestRetransmit there) NOTHING goes + // out — never an ack while ids are parked (§2.3's mutual + // exclusivity; the N2 ledger row shows why acking here would let + // ACE prune the lost id from its S2C cache before the NAK). + clock.Advance(TimeSpan.FromSeconds(2.5)); + transport.Sweep(); + transport.Sweep(); + Assert.Empty(sent); + Assert.Equal(0, transport.Stats.AcksSent); + + // The missing packet arrives (late delivery), clearing the set — + // the ack resumes at the next gate, which is long since due. + Admit(transport, 3u); + Assert.Equal(0, transport.Inbound.NakCount); + transport.Sweep(); + AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 4u); + } + + // ===================================================================== + // Ack-storm collapse — retail never acks per packet + // ===================================================================== + + [Fact] + public void CreateObjectFlood_InsideOneWindow_CollapsesToOneAck() + { + (ReliableTransport transport, VirtualClock clock, List sent) = + CreateTransport(); + + // A simulated CreateObject flood: 50 sequenced arrivals across + // 1.0 s, the per-frame sweep interleaved. The pre-N3 reflex ack + // sent 50 acks — one per packet; retail sends NONE until the gate. + uint sequence = 2; + for (int i = 0; i < 50; i++) + { + Admit(transport, sequence++); + clock.Advance(TimeSpan.FromMilliseconds(20)); + transport.Sweep(); + } + + Assert.Empty(sent); + + // Crossing the 2.0 s gate: exactly ONE cumulative ack for the + // whole flood, carrying the final watermark. + clock.Advance(TimeSpan.FromSeconds(1.0)); + transport.Sweep(); + AssertAckShape( + Assert.Single(sent), + expectedSequence: 1u, + expectedValue: 51u); + Assert.Equal(1, transport.Stats.AcksSent); + } + + // ===================================================================== + // Conformance against the N0 ACE-behaviour double (real WorldSession) + // ===================================================================== + + /// + /// The keepalive property the reflex ack used to provide, now proven + /// for the cumulative ack: a QUIET session (no game actions; the only + /// inbound is ACE's own TimeSync every 20 s and ack every 2 s) still + /// sends one cleartext ack per ~2 s, and each one refreshes ACE's 60 s + /// TimeoutDeadline (NetworkSession.cs:329-331) — the session survives + /// far past the 60 s horizon. + /// + [Fact] + public void QuietSession_CumulativeAcksKeepAceAlive_PastThe60sHorizon() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + session.TransportClockSource = + (transport.Clock.GetTimestamp, transport.Clock.Frequency); + try + { + session.Connect( + "testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + + // 120 virtual seconds in 0.5 s frames — double ACE's timeout + // horizon. The model's Update checks its deadline every pump. + for (int frame = 0; frame < 240; frame++) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); + transport.PumpServer(); + session.Tick(); + Thread.Sleep(1); + } + + Assert.False(transport.Model.IsTerminated); + Assert.Equal( + AceTerminationReason.None, + transport.Model.TerminationReason); + + // The deadline is FRESH — refreshed within the last ack + // interval, not merely unexpired. + long margin = transport.Model.TimeoutDeadlineTimestamp + - transport.Clock.GetTimestamp(); + Assert.True( + margin > TimeSpan.FromSeconds(50).Ticks, + $"TimeoutDeadline margin {margin} ticks — the acks are not refreshing it"); + + // ~60 acks expected over 120 s; ≥ 40 pins the ~2 s cadence + // without depending on frame phase. + long acksSent = session.Transport!.Stats.AcksSent; + Assert.True( + acksSent >= 40, + $"only {acksSent} cumulative acks over 120 virtual seconds"); + + Assert.Equal(0, transport.Model.CrcDropCount); + Assert.Equal(0, transport.Model.StateDropCount); + Assert.Equal(0, transport.Model.DuplicateDropCount); + Assert.Equal(0, session.Transport.Inbound.NakCount); + } + finally + { + session.Dispose(); + } + + Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); + } + + /// + /// Full lifecycle against the double: clean run completes, an in-world + /// S2C flood collapses to one ack at the next gate, the model timeout + /// never fires, and teardown is OUR graceful Disconnect — with zero + /// CRC/state/duplicate drops end to end. + /// + [Fact] + public void FullLifecycle_CleanRun_FloodCollapses_GracefulTeardown() + { + var transport = new FakeAceTransport(); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + session.TransportClockSource = + (transport.Clock.GetTimestamp, transport.Clock.Frequency); + try + { + session.Connect( + "testaccount", "testpassword", TimeSpan.FromSeconds(10)); + session.EnterWorld(0, TimeSpan.FromSeconds(10)); + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + + var messages = new List(); + session.ServerMessageReceived += m => messages.Add(m.Message); + + // An S2C flood inside one 2 s window: 20 messages, each pumped + // into its own sequenced packet (the pre-N3 reflex ack answered + // every one of them). + long acksBefore = session.Transport!.Stats.AcksSent; + for (int i = 0; i < 20; i++) + { + transport.Model.EnqueueGameMessage( + BuildServerMessage($"flood {i}"), + GameMessageGroup.UIQueue); + transport.PumpServer(); + } + + PumpUntil(session, () => messages.Count >= 20); + Assert.Equal(acksBefore, session.Transport.Stats.AcksSent); + + // Exactly ONE cumulative ack at the next gate covers the + // whole flood. + transport.Clock.Advance(TimeSpan.FromSeconds(2)); + session.Tick(); + Assert.Equal( + acksBefore + 1, + session.Transport.Stats.AcksSent); + + // A few more quiet gates keep flowing. + for (int frame = 0; frame < 10; frame++) + { + transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); + transport.PumpServer(); + session.Tick(); + Thread.Sleep(1); + } + + Assert.False(transport.Model.IsTerminated); + Assert.Equal(0, transport.Model.CrcDropCount); + Assert.Equal(0, transport.Model.StateDropCount); + Assert.Equal(0, transport.Model.DuplicateDropCount); + Assert.Equal(256, transport.Model.Crypto.Headroom); + Assert.Equal(0, session.Transport.Inbound.NakCount); + } + finally + { + session.Dispose(); + } + + // The model terminated on OUR transport Disconnect — the 60 s + // timeout never fired. + Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); + Assert.True(transport.Model.IsTerminated); + Assert.Equal( + AceTerminationReason.PacketHeaderDisconnect, + transport.Model.TerminationReason); + } + + // ===================================================================== + // Fixture helpers + // ===================================================================== + + /// A full transport on a virtual clock; every emitted datagram + /// (game sends AND scheduler acks) lands in Sent. + private static (ReliableTransport Transport, VirtualClock Clock, + List Sent) CreateTransport() + { + var virtualClock = new VirtualClock(); + var sent = new List(); + var transport = new ReliableTransport( + MakeIsaac(ClientSeed), + MakeIsaac(ServerSeed), + (ushort)ClientId, + datagram => sent.Add(datagram.ToArray()), + new TransportClock( + virtualClock.GetTimestamp, + virtualClock.Frequency)); + return (transport, virtualClock, sent); + } + + /// Admit one encrypted sequenced arrival that must not drop. + private static void Admit(ReliableTransport transport, uint sequence) + { + InboundSequenceTracker.Admission admission = + transport.Inbound.Admit(sequence, encrypted: true); + Assert.False(admission.Drop); + } + + /// + /// Pin the full 24-byte wire shape of the cumulative ack (campaign + /// §2.3): flags are an EQUALITY match on AckSequence — the raw + /// uint compare fails if ANY extra bit is ORed in (landmine #5) — + /// cleartext (decodes with a null keystream), 4-byte little-endian + /// body carrying the watermark, borrowed header sequence, session + /// client id, Time/Iteration zero. + /// + private static void AssertAckShape( + byte[] datagram, + uint expectedSequence, + uint expectedValue) + { + Assert.Equal(PacketHeader.Size + sizeof(uint), datagram.Length); + PacketHeader header = PacketHeader.Unpack(datagram); + Assert.Equal( + (uint)PacketHeaderFlags.AckSequence, + (uint)header.Flags); + Assert.Equal(expectedSequence, header.Sequence); + Assert.Equal((ushort)ClientId, header.Id); + Assert.Equal((ushort)0, header.Time); + Assert.Equal((ushort)0, header.Iteration); + Assert.Equal((ushort)sizeof(uint), header.DataSize); + Assert.Equal( + expectedValue, + BinaryPrimitives.ReadUInt32LittleEndian( + datagram.AsSpan(PacketHeader.Size))); + + // Cleartext: verifies additively with no ISAAC word. + PacketCodec.PacketDecodeResult decoded = + PacketCodec.TryDecode(datagram, inboundIsaac: null); + Assert.True(decoded.IsOk, decoded.Error.ToString()); + Assert.Equal(expectedValue, decoded.Packet!.Optional.AckSequence); + } + + /// An 8-byte message body whose first byte is a test marker. + private static byte[] MakeMessage(byte marker) => + new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 }; + + private static IsaacRandom MakeIsaac(uint seed) + { + Span seedBytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed); + return new IsaacRandom(seedBytes); + } + + /// A negotiated ACE double, matching the + /// OutboundReliableTransportTests fixture: LoginRequest → ConnectRequest + /// (discarded) → ConnectResponse → immediate first TimeSync (discarded; + /// S2C seq 2). + private static (AceSessionModel Model, VirtualClock Clock) + CreateNegotiatedModel() + { + var clock = new VirtualClock(); + var model = new AceSessionModel( + clock, ClientSeed, ServerSeed, ClientId, Cookie); + model.LoginRequestReceived += model.SendConnectRequest; + + byte[] login = PacketCodec.Encode( + new PacketHeader { Flags = PacketHeaderFlags.LoginRequest }, + LoginRequest.Build("testaccount", "testpassword", 1234), + outboundIsaac: null); + model.Receive(login); + model.Update(); + model.TakePendingDatagrams(); + + byte[] cookieBody = new byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(cookieBody, Cookie); + byte[] connectResponse = PacketCodec.Encode( + new PacketHeader + { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse }, + cookieBody, + outboundIsaac: null); + model.Receive(connectResponse); + model.Update(); + model.TakePendingDatagrams(); + + return (model, clock); + } + + private static void PumpUntil(WorldSession session, Func condition) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition() && DateTime.UtcNow < deadline) + { + session.Tick(); + Thread.Sleep(5); + } + + Assert.True(condition(), "condition not reached before the deadline"); + } + + private static byte[] BuildServerMessage(string text) + { + var writer = new PacketWriter(64 + text.Length); + writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 + writer.WriteString16L(text); + writer.WriteUInt32(1); // ChatMessageType + return writer.ToArray(); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs index 7a03a887..8b29ec27 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/OutboundReliableTransportTests.cs @@ -180,7 +180,9 @@ public sealed class OutboundReliableTransportTests Assert.Equal( PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum, originalHeader.Flags); - Assert.Equal((ushort)0, originalHeader.Time); + // N3 fold-in: fresh sends stamp the current interval id (the clock + // starts at 1) — FlowQueue::TransmitNewPackets @ 0x00547A60. + Assert.Equal((ushort)1, originalHeader.Time); // 1.2 s later (interval id 1 → 3) the server NAKs sequence 2. virtualClock.Advance(TimeSpan.FromSeconds(1.2)); @@ -323,6 +325,39 @@ public sealed class OutboundReliableTransportTests Assert.True(stats.AcksConsumed >= 1); } + /// + /// N3 fold-in of the N1 review advisory: retail stamps + /// CurLocalInterval_.intervalID_ into Header.Time on every + /// FRESH packet (FlowQueue::TransmitNewPackets @ 0x00547A60, the + /// header build at 0x00547A84), and a resend re-stamps the CURRENT + /// interval id (possibly newer than the fresh-send stamp). ACE never + /// reads inbound Header.Time, so this is wire-cosmetic against + /// ACE — but it is retail's behavior. + /// + [Fact] + public void FreshSend_StampsCurrentIntervalId_ResendRestampsNewer() + { + (OutboundFlowQueue queue, VirtualClock virtualClock, + TransportClock clock, _, List sent) = CreateQueue(); + + // K interval ticks before the send: 2.5 s = 5 intervals, id 1 → 6. + virtualClock.Advance(TimeSpan.FromSeconds(2.5)); + clock.Update(); + Assert.Equal((ushort)6, clock.IntervalId); + queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue); + Assert.Equal((ushort)6, PacketHeader.Unpack(Assert.Single(sent)).Time); + + // The interval advances again; the resend carries the CURRENT id, + // newer than the fresh-send stamp. + virtualClock.Advance(TimeSpan.FromSeconds(1.0)); + clock.Update(); + Assert.Equal((ushort)8, clock.IntervalId); + Nak(queue, 2u); + sent.Clear(); + queue.TransmitPendingResends(); + Assert.Equal((ushort)8, PacketHeader.Unpack(Assert.Single(sent)).Time); + } + [Fact] public void OnAckSequence_IsWrapSafeMax_AndNeverRegresses() { diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionNetReceiveLoopResilienceTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionNetReceiveLoopResilienceTests.cs index 2529f77c..8f671869 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionNetReceiveLoopResilienceTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionNetReceiveLoopResilienceTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Sockets; using System.Reflection; +using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Tests; @@ -64,16 +65,26 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests Assert.Equal(1, processed); } + /// + /// The loop + channel preserve arrival order end-to-end. Pre-N3 this + /// was asserted through the per-packet reflex acks; the AckNakScheduler + /// replaced those with one cumulative ack per 2.0 s (retail + /// SharedNet::EnqueuePak @ 0x00543B10), so the ordering witness + /// is now the dispatched message stream itself — and the session must + /// send NO per-packet acks at all. + /// [Fact] - public async Task NetReceiveLoopAsync_PreservesArrivalAndAckOrder() + public async Task NetReceiveLoopAsync_PreservesArrivalOrder_NoReflexAcks() { var transport = new OrderedDatagramTransport( - BuildPacket(sequence: 41), - BuildPacket(sequence: 42), - BuildPacket(sequence: 43)); + BuildPacket(sequence: 41, fragmentSequence: 1, "first"), + BuildPacket(sequence: 42, fragmentSequence: 2, "second"), + BuildPacket(sequence: 43, fragmentSequence: 3, "third")); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); + var messages = new List(); + session.ServerMessageReceived += m => messages.Add(m.Message); MethodInfo loopMethod = typeof(WorldSession).GetMethod( "NetReceiveLoopAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -82,29 +93,44 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests await task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(3, session.Tick()); - uint[] acked = transport.Sent - .Select(static bytes => - PacketCodec.TryDecode( - bytes, - inboundIsaac: null)) - .Select(static decoded => - { - Assert.True(decoded.IsOk, decoded.Error.ToString()); - return decoded.Packet!.Optional.AckSequence; - }) - .ToArray(); - Assert.Equal([41u, 42u, 43u], acked); + Assert.Equal(["first", "second", "third"], messages); + + // Retail never acks per packet: nothing goes out in response to + // inbound datagrams (the cumulative ack lives on the negotiated + // transport's 2.0 s sweep, and no transport was negotiated here). + Assert.Empty(transport.Sent); } - private static byte[] BuildPacket(uint sequence) => - PacketCodec.Encode( + private static byte[] BuildPacket( + uint sequence, + uint fragmentSequence, + string text) + { + byte[] message = BuildServerMessage(text); + byte[] body = new byte[MessageFragmentHeader.Size + message.Length]; + int written = GameMessageFragment.WriteSingleFragment( + body, + fragmentSequence, + GameMessageGroup.UIQueue, + message); + return PacketCodec.Encode( new PacketHeader { Sequence = sequence, - Flags = PacketHeaderFlags.None, + Flags = PacketHeaderFlags.BlobFragments, }, - ReadOnlySpan.Empty, + body.AsSpan(0, written), outboundIsaac: null); + } + + private static byte[] BuildServerMessage(string text) + { + var writer = new PacketWriter(64 + text.Length); + writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 + writer.WriteString16L(text); + writer.WriteUInt32(1); // ChatMessageType + return writer.ToArray(); + } private sealed class ScriptedTransport : IWorldSessionTransport {