feat(net): N4 - client NAK emission + RejectRetransmit reclaim

Campaign N slice N4 completes the AckNakScheduler NAK branch and closes
the ACE cleartext-reject keystream hazard - the slice that makes S2C
loss actually RECOVER.

NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0):
- One cleartext exact-flags RequestRetransmit per sweep behind the
  STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test
  at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays
  >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s
  and vice versa (landmine #7).
- Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks
  @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E);
  header Sequence borrowed from highestIDSent_ without incrementing;
  cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) -
  and a NAK never refreshes ACE's 60 s timeout.
- Control-header rule decided once for BOTH ack and NAK: Time = the
  interval id, Iteration = the session iteration, matching retail's
  shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the
  stack build at 0x00547A84). ACE reads neither field inbound.
- Gate ticks now round instead of truncate: 0.6 has no exact double
  form, and truncation opened the strict gate exactly AT the boundary.

RejectRetransmit reclaim (divergence register AD-51, ACE adaptation):
- ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO
  keystream word, and is cached (ACE NetworkSession.cs:299-304,
  :722-725, :743-748) - the one place ACE breaks retail's gap-walk
  invariant that every missing id was word-bearing (retail cleartext
  always borrows live sequences). Unhandled, the gap walk parks a word
  for the reject's id and the inbound stream runs permanently one word
  ahead - the N2 desync class reintroduced through the reject path.
- Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes
  the mis-park, shifts every later-drawn parked word down one position
  (per-word draw ordinals; ascending wrap-safe id <=> ascending draw
  order), and pools the excess word, consumed lowest-draw-order-first
  ahead of fresh ISAAC draws. Exact for any number of interleaved
  rejects in ANY arrival order - a plain reclaim FIFO is not: a reject
  arriving after a higher encrypted arrival crosses the parked chain,
  and two out-of-order rejects pool their excess words out of draw
  order (both orderings pinned by tests).
- Reject BODY ids keep N2's discard: word-bearing server-side,
  consumed-in-place. The pool is provably empty against retail servers.

N3 advisories folded (all five): honest transitional-state wording (the
empty N3 NAK branch could silently disconnect a loopback session at
ACE's 60 s timeout, witness [net-tick] acks/s=0), the
ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0)
citation, the FlowQueue::Empty pump-order wording (TransmitNaks ->
TransmitAcks -> TransmitNewPackets with the interval increment LAST @
0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the
Time/Iteration rule above, and the stale WorldSession budget-break
comment rewritten to the sweep reality.

Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3
pins): strict-gate boundary, shared timestamp both directions,
NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served
retransmission round trip, five tracker reclaim proofs, the 130 s
virtual prune -> fresh-sequence reject system test (victim abandoned,
later traffic decodes, pool drains to zero), 10 s long-loss survival
(NAKs on the gate cadence, zero acks, heal inside the window), and the
capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero
message loss both ways, ACE crypto headroom 256 at convergence, every
ledger drained (cache at the single watermark entry - retail's Flush
prunes STRICTLY below the ack). Full solution Release: 9,758 passed /
5 skipped. Connected world-lifecycle gate PASS
(logs/connected-world-gate-20260729-150238); canonical nine-stop soak
PASS (logs/connected-r6-soak-20260729-150856).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 15:20:35 +02:00
parent e9686401bc
commit 852a59e388
10 changed files with 1467 additions and 109 deletions

View file

@ -900,20 +900,22 @@ public sealed class WorldSession : IDisposable
// generation into connection-level control packets, including the
// final disconnect. ACE currently emits iteration 1.
_sessionIteration = connectRequestIteration;
// N1+N2+N3: the reliable transport is born at ISAAC-seeding time,
// owning BOTH keystreams and the ack/NAK sweep. Outbound:
// N1+N2+N3+N4: 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).
// shared ack/NAK gate arms here, at connection birth
// (ReceiverData::SharedInit @ 0x00548EF0, reached from
// ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time).
_transport = new ReliableTransport(
new IsaacRandom(clientSeedBytes),
new IsaacRandom(serverSeedBytes),
_sessionClientId,
_sessionIteration,
datagram => _net.Send(datagram),
clock: TransportClockSource is { } clockSource
? new TransportClock(
@ -1070,10 +1072,12 @@ public sealed class WorldSession : IDisposable
processed++;
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
// (the async receive owner starts at Transition(State.InWorld)).
// Acks are queued per packet inside ProcessDatagram BEFORE the heavy handler, so
// deferring the tail only delays the tail's acks a few frames — within ACE's
// tolerance (holtburger defers acks on a flush cadence). The tail stays queued
// (unbounded channel, FIFO) and drains next frame.
// Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on
// the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a
// deferred inbound tail never defers a due ack, NAK, or resend. The tail itself
// stays queued (unbounded channel, FIFO) and drains next frame — its only cost
// is that the cumulative ack keeps carrying the pre-tail watermark until the
// tail is processed, well inside ACE's 120 s cache retention.
if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks))
{
budgetBroke = true;
@ -1090,12 +1094,13 @@ public sealed class WorldSession : IDisposable
}
/// <summary>
/// N1: one reliable-transport pump slice (retail
/// N1+N3+N4: one reliable-transport pump slice (retail
/// <c>PacketController::UseTime @ 0x005410D0</c> shape): interval clock
/// forward, pending NAKed resends out, acked cache pruned. Gated on
/// negotiation — ACE's <c>Session.CheckState</c> silently discards
/// pre-negotiation control traffic (campaign landmine #8), and the
/// transport does not exist before the ISAAC seeds do.
/// forward, NAK-xor-ack arbitration, pending NAKed resends out, acked
/// cache pruned. Gated on negotiation — ACE's
/// <c>Session.CheckState</c> silently discards pre-negotiation control
/// traffic (campaign landmine #8), and the transport does not exist
/// before the ISAAC seeds do.
/// </summary>
private void SweepTransport()
{
@ -1385,7 +1390,8 @@ public sealed class WorldSession : IDisposable
{
inboundTransport.Inbound.ReparkKey(
serverHeader.Sequence,
admission.VerifyKey!.Value);
admission.VerifyKey!.Value,
admission.VerifyKeyDrawOrder);
}
return;
@ -1433,15 +1439,33 @@ public sealed class WorldSession : IDisposable
}
// N2: inbound RejectRetransmit (0x2000) — the server abandoned
// these ids; drop them from the NAK set, discarding the parked
// keys (SharedNet::HandleEmptyAck @ 0x005448F0). Alignment
// holds: the words were already drawn in sequence order.
if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0
&& packet.Optional.RejectRetransmitCount > 0)
// the ids in the BODY; drop them from the NAK set, discarding
// the parked keys (SharedNet::HandleEmptyAck @ 0x005448F0).
// Alignment holds for those ids: they were real encrypted
// packets, so their words were drawn on both sides and are
// consumed-in-place.
if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0)
{
transport.Inbound.OnRejectRetransmit(
packet.Optional.RejectRetransmitBytes.Span,
packet.Optional.RejectRetransmitCount);
if (packet.Optional.RejectRetransmitCount > 0)
{
transport.Inbound.OnRejectRetransmit(
packet.Optional.RejectRetransmitBytes.Span,
packet.Optional.RejectRetransmitCount);
}
// N4/AD-51 — the reject packet's OWN sequence is the
// opposite case: ACE consumed it fresh, cleartext, with NO
// keystream word (FlushPackets, NetworkSession.cs:722-725,
// :743-748), so the word our gap walk parked for it was
// never drawn server-side. Reclaim it (checksum already
// verified above — the trigger fires only on a VALIDATED
// packet). Retail never reaches this: its cleartext packets
// always borrow live sequences.
if (serverHeader.Sequence != 0 && !encrypted)
{
transport.Inbound.OnCleartextRejectSequence(
serverHeader.Sequence);
}
}
// Cumulative ack (AckSequence 0x4000): wrap-safe max into the