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

@ -20,6 +20,7 @@ public sealed class AckNakSchedulerTests
private const uint ClientSeed = 0x11AA22BBu;
private const uint ServerSeed = 0x33CC44DDu;
private const uint ClientId = 0x1234u;
private const ushort SessionIteration = 0x0007;
private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
// =====================================================================
@ -35,17 +36,25 @@ public sealed class AckNakSchedulerTests
Admit(transport, 3u);
// No ack before 2.0 s — the gate armed at transport construction
// (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time).
// (ReceiverData::SharedInit @ 0x00548EF0, reached from
// ReceiverData::Init @ 0x00548FA0, 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).
// `& 1` status test at 0x00543B3D). Header.Time carries the
// interval id at emission (N4 control-header rule): 2.0 s of
// 0.5 s intervals on top of the initial id 1 → 5.
clock.Advance(TimeSpan.FromSeconds(0.01));
transport.Sweep();
AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 3u);
Assert.Equal((ushort)5, transport.Clock.IntervalId);
AssertAckShape(
Assert.Single(sent),
expectedSequence: 1u,
expectedValue: 3u,
expectedTime: transport.Clock.IntervalId);
Assert.Equal(1, transport.Stats.AcksSent);
// The gate reset: silent until the next 2.0 s elapses.
@ -61,7 +70,11 @@ public sealed class AckNakSchedulerTests
clock.Advance(TimeSpan.FromSeconds(0.01));
transport.Sweep();
Assert.Equal(2, sent.Count);
AssertAckShape(sent[1], expectedSequence: 1u, expectedValue: 5u);
AssertAckShape(
sent[1],
expectedSequence: 1u,
expectedValue: 5u,
expectedTime: transport.Clock.IntervalId);
Assert.Equal(2, transport.Stats.AcksSent);
}
@ -126,23 +139,37 @@ public sealed class AckNakSchedulerTests
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).
// 2.5 s elapses with the NAK set non-empty: the NAK branch owns the
// sweep — ONE RequestRetransmit goes out and 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). The immediate second sweep is silenced by the
// freshly stamped shared timestamp.
clock.Advance(TimeSpan.FromSeconds(2.5));
transport.Sweep();
transport.Sweep();
Assert.Empty(sent);
byte[] nak = Assert.Single(sent);
Assert.Equal(
(uint)PacketHeaderFlags.RequestRetransmit,
(uint)PacketHeader.Unpack(nak).Flags);
Assert.Equal(0, transport.Stats.AcksSent);
Assert.Equal(1, transport.Stats.NaksSent);
sent.Clear();
// The missing packet arrives (late delivery), clearing the set —
// the ack resumes at the next gate, which is long since due.
// the ack resumes once 2.0 s elapse past the NAK's stamp of the
// SHARED timestamp (landmine #7: a NAK delays the next ack).
Admit(transport, 3u);
Assert.Equal(0, transport.Inbound.NakCount);
transport.Sweep();
AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 4u);
Assert.Empty(sent);
clock.Advance(TimeSpan.FromSeconds(2.0));
transport.Sweep();
AssertAckShape(
Assert.Single(sent),
expectedSequence: 1u,
expectedValue: 4u,
expectedTime: transport.Clock.IntervalId);
}
// =====================================================================
@ -175,7 +202,8 @@ public sealed class AckNakSchedulerTests
AssertAckShape(
Assert.Single(sent),
expectedSequence: 1u,
expectedValue: 51u);
expectedValue: 51u,
expectedTime: transport.Clock.IntervalId);
Assert.Equal(1, transport.Stats.AcksSent);
}
@ -343,6 +371,7 @@ public sealed class AckNakSchedulerTests
MakeIsaac(ClientSeed),
MakeIsaac(ServerSeed),
(ushort)ClientId,
SessionIteration,
datagram => sent.Add(datagram.ToArray()),
new TransportClock(
virtualClock.GetTimestamp,
@ -364,12 +393,15 @@ public sealed class AckNakSchedulerTests
/// 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, <c>Time</c>/<c>Iteration</c> zero.
/// client id, and the N4 control-header rule: <c>Time</c> = the
/// interval id at emission and <c>Iteration</c> = the session
/// iteration (retail's shared header build at 0x00547A84).
/// </summary>
private static void AssertAckShape(
byte[] datagram,
uint expectedSequence,
uint expectedValue)
uint expectedValue,
ushort expectedTime)
{
Assert.Equal(PacketHeader.Size + sizeof(uint), datagram.Length);
PacketHeader header = PacketHeader.Unpack(datagram);
@ -378,8 +410,8 @@ public sealed class AckNakSchedulerTests
(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(expectedTime, header.Time);
Assert.Equal(SessionIteration, header.Iteration);
Assert.Equal((ushort)sizeof(uint), header.DataSize);
Assert.Equal(
expectedValue,

View file

@ -101,9 +101,14 @@ public sealed class InboundSequenceTrackerTests
uint w11 = shadow.Next();
// 10 arrives corrupt: admission consumed w10, verification failed,
// the session re-parks the consumed key (step 5) and drops.
Assert.Equal(w10, Admitted(tracker, 10));
tracker.ReparkKey(10, w10);
// the session re-parks the consumed key (step 5) — carrying the
// admission's draw order, so the AD-51 reclaim can still place the
// word in the stream — and drops.
InboundSequenceTracker.Admission corrupt =
tracker.Admit(10, encrypted: true);
Assert.False(corrupt.Drop);
Assert.Equal(w10, corrupt.VerifyKey);
tracker.ReparkKey(10, w10, corrupt.VerifyKeyDrawOrder);
Assert.Equal(1, tracker.NakCount);
Assert.Equal(1, stats.KeysParked);

File diff suppressed because it is too large Load diff