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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 13:51:57 +02:00
parent 19bfb8477d
commit 0265cc4236
9 changed files with 797 additions and 127 deletions

View file

@ -6,19 +6,20 @@ namespace AcDream.Core.Net.Transport;
/// <summary>
/// Composition root for the session's reliable transport (campaign doc §4):
/// one <see cref="TransportClock"/>, the outbound flow queue (N1), the
/// inbound sequence tracker (N2), and the unconditional counters. The
/// <c>AckNakScheduler</c> joins in N3/N4 — until then ack behavior stays in
/// <c>WorldSession</c> untouched.
/// inbound sequence tracker (N2), the ack/NAK scheduler (N3), and the
/// unconditional counters.
///
/// <para>
/// <see cref="Sweep"/> is the once-per-frame pump slice retail runs from
/// <c>Client::UseTime @ 0x00411C40</c> →
/// <c>PacketController::UseTime @ 0x005410D0</c>: advance the interval
/// clock, serve pending retransmits, prune the acked cache. The session
/// calls it at the end of <c>Tick()</c> AND inside the blocking handshake
/// pump loops (landmine #8 — the EnterWorld flood precedes the first Tick),
/// gated on transport negotiation (ACE's <c>Session.CheckState</c> discards
/// early control traffic).
/// clock, arbitrate NAK-xor-ack, serve pending retransmits, prune the acked
/// cache. The session calls it at the end of <c>Tick()</c> AND inside the
/// blocking handshake pump loops (landmine #8 — the EnterWorld flood
/// precedes the first Tick; 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 <c>Session.CheckState</c>
/// discards early control traffic).
/// </para>
/// </summary>
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.</summary>
public InboundSequenceTracker Inbound { get; }
/// <summary>N3: retail's NAK-xor-ack sweep arbitration on the one
/// shared timestamp (<c>ClientNet::ProcessConnection @ 0x00545450</c>);
/// owns the 2.0 s cumulative <c>AckSequence</c>.</summary>
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;
}
/// <summary>Last reliable sequence on the wire — the value unsequenced
/// control packets (the reflex ack) borrow without incrementing.</summary>
/// control packets (the cumulative ack) borrow without incrementing.</summary>
public uint HighestIdSent => Outbound.HighestIdSent;
/// <summary>
/// One transport pump: interval clock forward, pending NAKed resends
/// out, acked cache entries pruned. Pump order per retail
/// <c>FlowQueue::Empty @ 0x00548A20</c> (NAK consumption already
/// happened at receive time; retransmits precede new packets — new
/// packets are sent synchronously by the session, so the sweep runs
/// before the frame's sends the same way retail's per-frame pump does).
/// One transport pump: interval clock forward, NAK-xor-ack arbitration,
/// pending NAKed resends out, acked cache entries pruned. Pump order per
/// retail <c>FlowQueue::Empty @ 0x00548A20</c>: the interval clock, then
/// the control-packet arbitration (<c>ClientNet::ProcessConnection
/// @ 0x00545450</c> 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.
/// </summary>
public void Sweep()
{
Clock.Update();
Scheduler.Sweep(Clock.GetTimestamp());
Outbound.TransmitPendingResends();
}