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
{