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

@ -4,7 +4,7 @@ using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Transport;
/// <summary>
/// Campaign N Slice N3: retail's per-frame ack/NAK arbitration
/// Campaign N Slices N3+N4: retail's per-frame ack/NAK arbitration
/// (<c>ClientNet::ProcessConnection @ 0x00545450</c>), replacing the
/// Phase 4.9 per-packet reflex ack. Retail never acks per packet —
/// <c>SharedNet::EnqueuePak @ 0x00543B10</c> is the ONLY
@ -17,14 +17,13 @@ namespace AcDream.Core.Net.Transport;
/// </para>
/// <list type="bullet">
/// <item>NAK set non-empty → the NAK branch
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>, 0.6 s gate on the SAME
/// timestamp) and NO ack this sweep. N4 emits the
/// <c>RequestRetransmit</c> 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.</item>
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>): when
/// <c>now sharedTimestamp &gt; 0.6 s</c> — STRICTLY greater; the x87
/// status test at 0x00543C03 masks 0x41 (C0 less | C3 equal) and jumps
/// away, so the branch proceeds only past the boundary, in contrast to the
/// ack's ≥ — emit ONE cleartext <c>RequestRetransmit</c> carrying up to
/// 114 parked ids ascending (<c>ReceiverData::GetNaks @ 0x005490C0</c>,
/// cap 0x72), stamp the shared timestamp, and send NO ack this sweep.</item>
/// <item>Else, when <c>now sharedTimestamp ≥ 2.0 s</c>: ONE cumulative
/// <c>AckSequence</c> carrying the tracker's <c>highestIDReceived_</c>
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>), then
@ -32,23 +31,47 @@ namespace AcDream.Core.Net.Transport;
/// </list>
///
/// <para>
/// The gate is armed at construction: retail's <c>ReceiverData::Init</c>
/// (@ 0x00548EF0, the <c>timeStamp_ = Timer::cur_time</c> 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 <c>sendAck</c> 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.
/// Historical note (N3 → N4): N3 shipped this class with the NAK branch
/// intentionally empty — parked ids suppressed the ack and emitted nothing.
/// The exposure was real even on loopback, just low-probability: one
/// receive-buffer drop parks an id, every subsequent sweep takes the silent
/// NAK branch, acks stop (witness: <c>[net-tick] acks/s=0</c>), and ACE
/// disconnects the quiet session at its 60 s timeout with no error packet.
/// N4 completed the branch; the emission below closes that window.
/// </para>
///
/// <para>
/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125):
/// flags EXACTLY <see cref="PacketHeaderFlags.AckSequence"/> — 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
/// <c>Sequence</c> borrowed from <c>highestIDSent_</c> without incrementing,
/// <c>Id</c> = the session client id, <c>Time</c>/<c>Iteration</c> zero.
/// The gate is armed at construction: retail's
/// <c>ReceiverData::SharedInit @ 0x00548EF0</c> (reached from
/// <c>ReceiverData::Init @ 0x00548FA0</c>; the <c>timeStamp_ =
/// Timer::cur_time</c> 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 <c>sendAck</c> arming
/// (NetworkSession.cs:54-55). Do NOT shorten the gates: ACE's S2C cache
/// holds 120 s, ACE's own ack cadence is the same 2 s, and ACE rate-limits
/// its own NAKs at 1 s.
/// </para>
///
/// <para>
/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125), the
/// control-packet header rule decided once for BOTH emissions: retail
/// transmits control headers through the same header build as fresh
/// reliable packets (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>,
/// the stack build at 0x00547A84), which stamps <c>Time</c> =
/// <c>CurLocalInterval_.intervalID_</c> and <c>Iteration</c> = the
/// receiver iteration — so the ack and the NAK here stamp
/// <see cref="TransportClock.IntervalId"/> and the session iteration. ACE
/// reads neither field inbound (campaign §3), so the stamps are
/// retail-faithfulness, not ACE compatibility. Both packets are cleartext
/// (no ISAAC word) with the header <c>Sequence</c> borrowed from
/// <c>highestIDSent_</c> without incrementing. Flags are EQUALITY-exact,
/// never an OR: the ack must be exactly
/// <see cref="PacketHeaderFlags.AckSequence"/> (landmine #5 — ACE's dedup
/// exemption at NetworkSession.cs:342-343 and the watermark-skip at
/// :474-476 both require the exact value) and the NAK exactly
/// <see cref="PacketHeaderFlags.RequestRetransmit"/> (landmine #6 — ACE
/// honours only the cleartext form, NetworkSession.cs:283-284, and a NAK
/// never refreshes ACE's 60 s timeout).
/// </para>
///
/// <para>
@ -64,17 +87,31 @@ internal sealed class AckNakScheduler
/// 2.0 at 0x00543B32).</summary>
public const double AckGateSeconds = 2.0;
/// <summary>Retail's NAK gate
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>, the x87 compare against
/// 0.6 at 0x00543BF8/0x00543C03 — strictly greater opens it).</summary>
public const double NakGateSeconds = 0.6;
/// <summary>Retail's NAK-list cap
/// (<c>ReceiverData::GetNaks @ 0x005490C0</c> clamps the returned count
/// at 0x72 = 114; ACE's own inbound cap is 115, NetworkSession.cs:381).</summary>
public const int MaxNakIdsPerPacket = 114;
private readonly TransportClock _clock;
private readonly InboundSequenceTracker _inbound;
private readonly OutboundFlowQueue _outbound;
private readonly TransportStats _stats;
private readonly DatagramSendDelegate _send;
private readonly ushort _sessionClientId;
private readonly ushort _sessionIteration;
private readonly long _ackGateTicks;
private readonly long _nakGateTicks;
private readonly List<uint> _nakScratch = new();
/// <summary>THE shared timestamp (<c>ReceiverData::timeStamp_</c>
/// @ +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.</summary>
/// @ +0x10). Both gates read and write this exact field — never
/// introduce a second timestamp (landmine #7): a NAK delays the next
/// ack by 2.0 s and an ack delays the next NAK by 0.6 s.</summary>
private long _sharedTimestamp;
public AckNakScheduler(
@ -82,6 +119,7 @@ internal sealed class AckNakScheduler
InboundSequenceTracker inbound,
OutboundFlowQueue outbound,
ushort sessionClientId,
ushort sessionIteration,
TransportStats stats,
DatagramSendDelegate send)
{
@ -91,14 +129,21 @@ internal sealed class AckNakScheduler
ArgumentNullException.ThrowIfNull(stats);
ArgumentNullException.ThrowIfNull(send);
_clock = clock;
_inbound = inbound;
_outbound = outbound;
_sessionClientId = sessionClientId;
_sessionIteration = sessionIteration;
_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.
// Round, don't truncate: 0.6 has no exact double form and
// 0.6 × 10^7 truncates to 5,999,999 ticks — one tick short, which
// would open the STRICT gate at exactly 0.6 s elapsed.
_ackGateTicks = (long)Math.Round(AckGateSeconds * clock.Frequency);
_nakGateTicks = (long)Math.Round(NakGateSeconds * clock.Frequency);
// ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0)
// stamps timeStamp_ = cur_time at connection birth: the gates start
// armed, first ack at +2.0 s.
_sharedTimestamp = clock.GetTimestamp();
}
@ -112,12 +157,15 @@ internal sealed class AckNakScheduler
{
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.
// SharedNet::EnqueueNaks @ 0x00543BD0 — proceed only when the
// elapsed time is STRICTLY greater than 0.6 s (the 0x41 mask
// test at 0x00543C03 bails on less-than OR equal). At exactly
// 0.6 s the gate stays closed. Never an ack in a NAK sweep.
if (now - _sharedTimestamp <= _nakGateTicks)
return;
EmitRequestRetransmit();
_sharedTimestamp = now;
return;
}
@ -134,8 +182,7 @@ internal sealed class AckNakScheduler
/// <summary>
/// Build and send the one cumulative <c>AckSequence</c>
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>: mask 0x4000, 4-byte
/// payload = <c>highestIDReceived_</c>), byte-shaped exactly like the
/// pre-N3 reflex ack except the VALUE is the cumulative watermark.
/// payload = <c>highestIDReceived_</c>).
/// </summary>
private void EmitCumulativeAck()
{
@ -154,6 +201,11 @@ internal sealed class AckNakScheduler
Sequence = _outbound.HighestIdSent,
Flags = PacketHeaderFlags.AckSequence,
Id = _sessionClientId,
// The control-packet header rule (class doc): Time = interval
// id, Iteration = receiver iteration, per the shared retail
// header build at 0x00547A84. ACE reads neither inbound.
Time = _clock.IntervalId,
Iteration = _sessionIteration,
};
int datagramLength = PacketCodec.FinalizeInPlace(
@ -165,4 +217,59 @@ internal sealed class AckNakScheduler
_send(datagram.Slice(0, datagramLength));
_stats.AcksSent++;
}
/// <summary>
/// Build and send the one <c>RequestRetransmit</c>
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>: mask 0x1000, body =
/// u32 count + count × u32 ids, data length <c>4·count + 4</c> — the
/// <c>m_cbData</c> store at 0x00543C3E — ids ascending from the AVL
/// walk, count capped at 114 by <c>ReceiverData::GetNaks
/// @ 0x005490C0</c>). Cleartext with flags EXACTLY
/// <c>RequestRetransmit</c> (landmine #6): ACE serves it at
/// NetworkSession.cs:283-308 and silently ignores any encrypted form.
/// The NAK set itself is untouched — parked entries (and their keys)
/// live until the retransmission decodes or the server abandons them.
/// </summary>
private void EmitRequestRetransmit()
{
_inbound.CopyNakkedSequencesAscending(_nakScratch, MaxNakIdsPerPacket);
int count = _nakScratch.Count;
int bodyLength = sizeof(uint) + count * sizeof(uint);
Span<byte> datagram = stackalloc byte[
PacketHeader.Size + sizeof(uint)
+ MaxNakIdsPerPacket * sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(
datagram.Slice(PacketHeader.Size),
(uint)count);
for (int i = 0; i < count; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
datagram.Slice(
PacketHeader.Size + sizeof(uint) + i * sizeof(uint)),
_nakScratch[i]);
}
var header = new PacketHeader
{
// Borrowed sequence, exactly like the ack: the NAK is not part
// of the reliable stream. ACE's cleartext-NAK early handling
// (NetworkSession.cs:283-308) runs BEFORE its duplicate
// rejection, so the reused sequence is never dedup-dropped.
Sequence = _outbound.HighestIdSent,
Flags = PacketHeaderFlags.RequestRetransmit,
Id = _sessionClientId,
Time = _clock.IntervalId,
Iteration = _sessionIteration,
};
int datagramLength = PacketCodec.FinalizeInPlace(
header,
datagram,
bodyLength,
optionalLength: bodyLength,
outboundIsaac: null);
_send(datagram.Slice(0, datagramLength));
_stats.NaksSent++;
}
}

View file

@ -53,6 +53,43 @@ namespace AcDream.Core.Net.Transport;
/// </list>
///
/// <para>
/// <b>The reclaimed-word pool (N4, ACE-only machinery — register AD-51).</b>
/// Retail never assigns fresh sequences to cleartext packets (acks and NAKs
/// borrow; <c>FlushPackets</c> has no cleartext fresh-sequence path), so the
/// gap walk's assumption "every missing id was an encrypted packet whose
/// keystream word the server drew" holds on retail and the pool below is
/// provably empty against a retail server. ACE breaks the invariant in ONE
/// place: <c>RejectRetransmit</c> consumes a fresh sequence, cleartext, NO
/// keystream word, and is cached (ACE NetworkSession.cs:299-304 →
/// FlushPackets :710-735). Our gap walk therefore parks a word for an id the
/// server never drew one for, and the whole inbound stream runs one word
/// ahead — the exact desync class N2 closed, reintroduced through the reject
/// path. The fix, on a VALIDATED cleartext reject
/// (<see cref="OnCleartextRejectSequence"/>):
/// <list type="bullet">
/// <item>remove the reject's own parked entry — its word was mis-drawn;</item>
/// <item>shift every parked entry drawn AFTER it down to its predecessor's
/// word (those parks counted the reject's id as word-consuming, so each sits
/// exactly one server position ahead);</item>
/// <item>push the excess word — the newest in the shifted chain — into the
/// reclaim pool, consumed lowest-draw-order-first by the next fresh
/// draws.</item>
/// </list>
/// Draw order is tracked per parked word (<see cref="ParkedWord"/>) because
/// interleaved rejects can push excess words out of arrival order; consuming
/// the pool in draw order is what keeps our stream identical to the server's
/// ("its" next word is always our earliest unconsumed draw). The ids listed
/// INSIDE a reject body are the opposite case and keep N2's behavior: they
/// were real encrypted packets the server pruned, their words WERE drawn
/// server-side, so their parked words stay discarded (consumed-in-place).
/// One unreachable corner is accepted: a reject whose OWN id later appears
/// inside another reject's body (the first reject pruned after 120 s of
/// sustained loss while the session survives) would discard a word the
/// server never drew — probabilistically impossible against ACE's 60 s
/// timeout and the 0.6 s NAK cadence.
/// </para>
///
/// <para>
/// Single-threaded by design (the ISAAC keystream is order-sensitive), like
/// the rest of the transport: every member runs on the session's frame
/// thread.
@ -86,19 +123,48 @@ internal sealed class InboundSequenceTracker
private readonly IsaacRandom _inboundIsaac;
private readonly TransportStats _stats;
/// <summary>One pre-drawn keystream word plus its position in our draw
/// sequence. The draw ordinal is what lets the AD-51 reject reclaim keep
/// the pool aligned to the server's stream when rejects interleave —
/// within the NAK set, ascending wrap-safe id ⇔ ascending draw order
/// (walks park in sequence order, later walks park later ids, and the
/// pool is consumed lowest-first before any fresh draw).</summary>
internal readonly record struct ParkedWord(uint Word, ulong DrawOrder);
/// <summary>Retail <c>m_SeqIDsWeNAKed</c>: missing sequence → the
/// pre-drawn keystream word parked for it.</summary>
private readonly SortedDictionary<uint, uint> _nakSet = new();
private readonly SortedDictionary<uint, ParkedWord> _nakSet = new();
/// <summary>The AD-51 reclaim pool: words we drew for ids ACE never drew
/// one for (cleartext RejectRetransmit fresh sequences), consumed
/// lowest-draw-order-first by <see cref="NextWord"/> before any fresh
/// ISAAC draw. Provably empty against a retail server.</summary>
private readonly PriorityQueue<uint, ulong> _reclaimedWords = new();
/// <summary>Scratch for the reject bubble-shift — reused, never
/// allocated on the steady-state path.</summary>
private readonly List<uint> _rejectShiftScratch = new();
/// <summary>Monotonic ordinal stamped on every fresh inbound ISAAC
/// draw; positions in this sequence ARE server stream positions minus
/// the reclaimed (never-drawn-server-side) entries.</summary>
private ulong _drawOrdinal;
/// <summary>Retail <c>highestIDReceived_</c> — the newest sequence ever
/// admitted (NOT "highest fully processed"; the gap walk advances it
/// past holes).</summary>
public uint HighestIdReceived { get; private set; }
/// <summary>Missing ids currently carrying a parked key — the value
/// N4's <c>RequestRetransmit</c> emission drains.</summary>
/// <summary>Missing ids currently carrying a parked key — the set the
/// N4 <c>RequestRetransmit</c> emission copies (never drains: entries
/// leave only on parked-key decode or RejectRetransmit abandonment).</summary>
public int NakCount => _nakSet.Count;
/// <summary>Words currently sitting in the AD-51 reclaim pool. Zero
/// against retail servers; against ACE it drains as the next fresh
/// draws consume it — a converged session ends at zero.</summary>
public int ReclaimedWordCount => _reclaimedWords.Count;
public InboundSequenceTracker(
IsaacRandom inboundIsaac,
TransportStats stats,
@ -115,14 +181,23 @@ internal sealed class InboundSequenceTracker
/// The verdict for one arriving sequenced packet. <see cref="Drop"/> —
/// discard without touching the checksum. Otherwise verify with
/// <see cref="VerifyKey"/>: the keystream word for encrypted packets,
/// null for the additive cleartext form.
/// null for the additive cleartext form. <see cref="VerifyKeyDrawOrder"/>
/// travels with the key so a checksum-failure re-park
/// (<see cref="ReparkKey"/>) keeps the word's draw position — the AD-51
/// reject reclaim needs it to know which parked entries a mis-park
/// shifted.
/// </summary>
public readonly record struct Admission(bool Drop, uint? VerifyKey)
public readonly record struct Admission(
bool Drop,
uint? VerifyKey,
ulong VerifyKeyDrawOrder)
{
public static Admission Dropped => new(true, null);
public static Admission Dropped => new(true, null, 0);
public static Admission Process(uint? verifyKey) =>
new(false, verifyKey);
public static Admission Process(
uint? verifyKey,
ulong verifyKeyDrawOrder = 0) =>
new(false, verifyKey, verifyKeyDrawOrder);
}
/// <summary>
@ -151,10 +226,10 @@ internal sealed class InboundSequenceTracker
// miss is a duplicate of an already-decoded packet and drops at
// zero keystream cost. Cleartext packets skip this entirely
// (retail reprocesses cleartext dups; they never touch the wheel).
uint? parkedKey = null;
ParkedWord? parkedKey = null;
if (encrypted && !newer)
{
if (!_nakSet.Remove(sequence, out uint parked))
if (!_nakSet.Remove(sequence, out ParkedWord parked))
{
_stats.InboundDupsDropped++;
return Admission.Dropped;
@ -186,8 +261,10 @@ internal sealed class InboundSequenceTracker
return Admission.Process(null);
// Step 4 — the packet's own key: parked when step 2 found one,
// else the next fresh word.
return Admission.Process(parkedKey ?? _inboundIsaac.Next());
// else the next word (the AD-51 reclaim pool ahead of a fresh
// ISAAC draw — identical against retail, where the pool is empty).
ParkedWord own = parkedKey ?? NextWord();
return Admission.Process(own.Word, own.DrawOrder);
}
/// <summary>
@ -196,13 +273,16 @@ internal sealed class InboundSequenceTracker
/// (<c>ProcessPacket @ 0x00544790</c> tail,
/// <c>AddNakked(seq, &amp;key)</c>) so the byte-identical retransmission
/// decodes with the same word. Idempotent like retail's AddNakked.
/// <paramref name="drawOrder"/> is the admission's
/// <see cref="Admission.VerifyKeyDrawOrder"/> — the word keeps its draw
/// position across the re-park.
/// </summary>
public void ReparkKey(uint sequence, uint key)
public void ReparkKey(uint sequence, uint key, ulong drawOrder)
{
if (_nakSet.ContainsKey(sequence))
return;
_nakSet.Add(sequence, key);
_nakSet.Add(sequence, new ParkedWord(key, drawOrder));
_stats.KeysParked++;
}
@ -228,17 +308,94 @@ internal sealed class InboundSequenceTracker
}
/// <summary>
/// Copy the NAKed ids in ascending raw-uint order — the same in-order
/// enumeration retail's AVL yields (<c>ReceiverData::GetNaks
/// @ 0x005490C0</c> walks it ascending for the ≤114-id NAK list). N4
/// adds the cap; this is the simple full copy.
/// Copy up to <paramref name="maxCount"/> NAKed ids in ascending
/// raw-uint order — the same in-order enumeration retail's AVL yields,
/// with retail's cap applied at the same layer
/// (<c>ReceiverData::GetNaks @ 0x005490C0</c> walks the tree ascending
/// and clamps the returned count at 0x72 = 114). The set itself is
/// untouched: entries leave only on parked-key decode, abandonment, or
/// the AD-51 reclaim.
/// </summary>
public void CopyNakkedSequencesAscending(List<uint> destination)
public void CopyNakkedSequencesAscending(
List<uint> destination,
int maxCount = int.MaxValue)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach (uint sequence in _nakSet.Keys)
{
if (destination.Count >= maxCount)
break;
destination.Add(sequence);
}
}
/// <summary>
/// AD-51 (N4): a VALIDATED cleartext <c>RejectRetransmit</c> arrived at
/// <paramref name="sequence"/> — an id ACE consumed fresh WITHOUT
/// drawing a keystream word (ACE FlushPackets, NetworkSession.cs:722-725
/// takes NextValue for the reject; SendPacket :743-748 draws no word for
/// a cleartext packet). Our gap walk parked a word for it under the
/// retail invariant "missing ⇒ encrypted ⇒ word-bearing"; undo the
/// mis-park exactly:
/// <list type="number">
/// <item>remove the reject's parked entry;</item>
/// <item>shift every parked entry drawn after it down to its
/// predecessor's word — each was assigned one draw position past where
/// the server's stream really sits;</item>
/// <item>push the chain's newest word into the reclaim pool for the
/// next fresh draw (lowest draw order first).</item>
/// </list>
/// Idempotent: a retransmitted reject (flags
/// <c>RejectRetransmit|Retransmission</c>, still cleartext) finds no
/// entry and no-ops. The caller triggers this only after checksum
/// verification — an unvalidated datagram must never move keystream
/// state. Retail comparison point: no such path exists in the retail
/// client because retail servers never assign fresh sequences to
/// cleartext packets (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>
/// sequences only reliable packets; control emissions borrow).
/// </summary>
public void OnCleartextRejectSequence(uint sequence)
{
if (!_nakSet.Remove(sequence, out ParkedWord reclaimed))
return;
// Everything drawn after the mis-park sits one server position
// ahead. Ascending draw order ⇔ ascending wrap-safe id inside the
// set, so ordering by draw ordinal is both wrap-proof and exactly
// "the ids newer than the reject".
_rejectShiftScratch.Clear();
foreach (KeyValuePair<uint, ParkedWord> entry in _nakSet)
{
if (entry.Value.DrawOrder > reclaimed.DrawOrder)
_rejectShiftScratch.Add(entry.Key);
}
_rejectShiftScratch.Sort(
(a, b) => _nakSet[a].DrawOrder.CompareTo(_nakSet[b].DrawOrder));
ParkedWord carry = reclaimed;
foreach (uint id in _rejectShiftScratch)
{
ParkedWord displaced = _nakSet[id];
_nakSet[id] = carry;
carry = displaced;
}
_reclaimedWords.Enqueue(carry.Word, carry.DrawOrder);
_stats.RejectWordsReclaimed++;
}
/// <summary>The next inbound word in server-stream order: the reclaim
/// pool's earliest draw when one is waiting (AD-51 — the server is
/// still on that position), else a fresh ISAAC draw stamped with the
/// next ordinal.</summary>
private ParkedWord NextWord()
{
if (_reclaimedWords.TryDequeue(out uint word, out ulong order))
return new ParkedWord(word, order);
return new ParkedWord(_inboundIsaac.Next(), ++_drawOrdinal);
}
/// <summary>
@ -251,7 +408,7 @@ internal sealed class InboundSequenceTracker
if (_nakSet.ContainsKey(sequence))
return;
_nakSet.Add(sequence, _inboundIsaac.Next());
_nakSet.Add(sequence, NextWord());
_stats.KeysParked++;
}
}

View file

@ -44,6 +44,7 @@ internal sealed class ReliableTransport : IDisposable
IsaacRandom outboundIsaac,
IsaacRandom inboundIsaac,
ushort sessionClientId,
ushort sessionIteration,
DatagramSendDelegate send,
TransportClock? clock = null,
ArrayPool<byte>? pool = null)
@ -63,6 +64,7 @@ internal sealed class ReliableTransport : IDisposable
Inbound,
Outbound,
sessionClientId,
sessionIteration,
Stats,
send);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
@ -74,13 +76,16 @@ internal sealed class ReliableTransport : IDisposable
/// <summary>
/// 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.
/// pending NAKed resends out, acked cache entries pruned. Retail's
/// <c>FlowQueue::Empty @ 0x00548A20</c> drains
/// <c>TransmitNaks → TransmitAcks → TransmitNewPackets</c> and advances
/// the interval clock LAST (the 0.5 s walk +
/// <c>IncrementLocalInterval</c> at 0x00548A9D); our Sweep advances the
/// clock FIRST. The divergence is cosmetic against ACE — it only shifts
/// which interval id lands in <c>Header.Time</c> at an interval
/// boundary, and ACE never reads that field inbound (campaign §3). 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()
{

View file

@ -52,6 +52,16 @@ internal sealed class TransportStats
/// only ack construction site; there is no per-packet ack).</summary>
public long AcksSent;
/// <summary>N4: <c>RequestRetransmit</c> packets emitted by the 0.6 s
/// NAK branch (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c> →
/// <c>ReceiverData::GetNaks @ 0x005490C0</c>, ≤114 ids each).</summary>
public long NaksSent;
/// <summary>N4: mis-parked keystream words reclaimed from validated
/// cleartext <c>RejectRetransmit</c> sequences (the AD-51 ACE
/// adaptation; always zero against a retail server).</summary>
public long RejectWordsReclaimed;
/// <summary>Live sent-packet cache depth — the N5 watchdog value
/// (<c>cache=N</c> in <c>[net-tick]</c>; the cache is unbounded like
/// retail's, so depth is the health signal, not a cap).</summary>

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