feat(net): N6 - ConnectResponse retransmit + fragment assembler eviction

Campaign N Slice N6, the final implementation slice.

ConnectResponse handshake retransmit:
- While the connection is unconfirmed, the Connect character-list pump
  resends the IDENTICAL cleartext ConnectResponse (same sequence 1, same
  cookie, the one encoded datagram - no new outbound state) on retail's
  strict 0.333333333 s gate. Retail: ClientNet::ProcessConnection
  @ 0x00545450, case cs_ConnectionRequestAcked @ 0x0054547B (the constant
  load at 0x00545481; the mask-0x41 strictly-greater x87 test at
  0x0054548C); ClientNet::SendConnectAck @ 0x005440F0 re-stamps
  lastSentHandshake_ (0x00544102) and rebuilds the same cookie packet.
- Confirmation = the first checksum-valid post-negotiation packet whose
  header lacks the ConnectRequest flag: retail's cs_ConnectionRequestAcked
  -> cs_Connected edge (ClientNet::ProcessPacket @ 0x00545100, the 0x40000
  exclusion at 0x0054514E, SetConnectionState(..., 5) at 0x00545160).
- The cadence rides the TransportClock (virtual-clock testable through
  TransportClockSource); the Connect deadline stays wall-clock.
- ACE safety pinned against the N0 model: a duplicate while still
  AuthConnectResponse re-routes idempotently through NetworkManager's
  pre-route; after acceptance CheckState clause 2 drops it pre-CRC at
  zero keystream cost.
- Pre-N6, one lost ConnectResponse was a hang to the Connect deadline;
  the N5 decorator deliberately arms after this window, so nothing
  covered it.

FragmentAssembler eviction (divergence register row AD-52):
- Partials evict 60 s after their last ACCEPTED fragment; the stamp
  refreshes on every new fragment (retail's re-stamp rule,
  ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00), so a merely-slow partial
  can never age out - 60 s is a floor, not a tunable. Swept from
  ReliableTransport.Sweep on retail's 5 s flush cadence
  (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the gate at 0x0054A3DC;
  per-entry ArrivedEphInfo::fTimedOut @ 0x0054AE30). N4's RejectRetransmit
  abandonment made an unrecoverable partial a REACHABLE permanent state;
  the TTL reclaims it.
- A 64-entry completed-sequence ring drops late duplicate fragments of
  already-completed messages instead of allocating a fresh partial that
  can never complete (the completed-then-duplicate leak).

Fold-ins:
- N5 review LOW-5: NetProbeTests + LossyTransportDecoratorTests (the
  static NetDiagnostics / Console.SetOut mutators) share one
  DisableParallelization xunit collection so they never run alongside
  classes constructing WorldSession.
- Campaign section 9: N6 ledger row recorded; N5 row verified carrying
  4e290f00.

Gates: 757 Core.Net Release tests green (10 new); full solution Release
green (0 failures / 5 skips); connected lifecycle gate PASS; the
N5-strengthened connected loss gate PASS on its first live run (2%/seed 1:
dropped out=3 in=10, resends=1 nak-in=1 nak-out=5, cksum-fail=0
sanity-drop=0 uncached-nak=0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 17:20:12 +02:00
parent 3899ebe0fd
commit f9c5e47e7f
10 changed files with 691 additions and 19 deletions

View file

@ -67,13 +67,13 @@ internal sealed class NetClientWorldSessionTransport(IPEndPoint remote)
/// </code>
///
/// <para>
/// <b>Still deferred:</b> unsolicited-disconnect recovery and the optional
/// N6 handshake hardening (ConnectResponse 0.333 s retransmit). The full
/// <b>Still deferred:</b> unsolicited-disconnect recovery. The full
/// Campaign N reliable transport is live in both directions: outbound
/// sent-packet cache + resend on server NAK (N1), inbound sequence-aligned
/// ISAAC + NAK set (N2), the retail 2.0 s cumulative-ack sweep (N3), client
/// NAK emission + RejectRetransmit reclaim (N4), and the N5 loss
/// observability + deterministic loss injection seam.
/// NAK emission + RejectRetransmit reclaim (N4), the N5 loss observability
/// + deterministic loss injection seam, and the N6 handshake hardening
/// (ConnectResponse 0.333 s retransmit + fragment-assembler eviction).
/// </para>
/// </summary>
public sealed class WorldSession : IDisposable
@ -680,6 +680,31 @@ public sealed class WorldSession : IDisposable
private ushort _sessionIteration;
private bool _transportNegotiated;
/// <summary>
/// N6: retail's ConnectResponse resend cadence — the x87 compare against
/// 0.333333333 in <c>ClientNet::ProcessConnection @ 0x00545450</c>
/// (case <c>cs_ConnectionRequestAcked</c> at 0x0054547B; the constant
/// load at 0x00545481). The mask-0x41 status test at 0x0054548C bails on
/// less-than OR equal, so the gate opens only STRICTLY past the
/// boundary — the same strict shape as the N4 NAK gate.
/// </summary>
internal const double ConnectResponseRetrySeconds = 0.333333333;
/// <summary>
/// N6: true once ANY checksum-valid post-negotiation server packet has
/// been decoded — the port of retail's connection confirmation:
/// <c>ClientNet::ProcessPacket @ 0x00545100</c> promotes
/// <c>cs_ConnectionRequestAcked → cs_Connected</c> (the
/// <c>SetConnectionState(..., 5)</c> vtable call at 0x00545160) on the
/// first successfully processed packet whose header lacks the
/// ConnectRequest flag (the 0x40000 test at 0x0054514E), and the resend
/// case never fires again. While false, the Connect pump resends the
/// IDENTICAL cleartext ConnectResponse (same sequence 1, same cookie —
/// no new outbound state) every
/// <see cref="ConnectResponseRetrySeconds"/>.
/// </summary>
private bool _handshakeConfirmed;
/// <summary>
/// Campaign N Slices N1+N2: the reliable transport — both ISAAC
/// keystreams, packet/fragment sequences, sent-packet cache, resend on
@ -930,7 +955,10 @@ public sealed class WorldSession : IDisposable
? new TransportClock(
clockSource.GetTimestamp,
clockSource.Frequency)
: null);
: null,
// N6: the sweep ages out abandoned fragment partials (5 s
// cadence, 60 s TTL — FragmentAssembler doc + AD-52).
assembler: _assembler);
_transportNegotiated = true;
// Publish only after the receiver identity and crypto state are fully
@ -942,16 +970,44 @@ public sealed class WorldSession : IDisposable
byte[] crBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
byte[] connectResponseDatagram = PacketCodec.Encode(crHeader, crBody, null);
Thread.Sleep(200);
_net.Send(_connectEndpoint, PacketCodec.Encode(crHeader, crBody, null));
_net.Send(_connectEndpoint, connectResponseDatagram);
// N6: arm the handshake resend clock. Retail stamps
// lastSentHandshake_ inside every ClientNet::SendConnectAck
// (@ 0x005440F0, the store at 0x00544102) and rebuilds the same
// ConnectResponse from the stored cookie each time; we keep the one
// encoded datagram and resend it verbatim — identical cleartext,
// sequence 1, no new state consumed. The cadence rides the
// transport clock so the conformance suite can drive it on virtual
// time; the Connect deadline stays wall-clock.
TransportClock handshakeClock = _transport.Clock;
long handshakeRetryTicks = (long)Math.Round(
ConnectResponseRetrySeconds * handshakeClock.Frequency);
long handshakeSentTimestamp = handshakeClock.GetTimestamp();
Transition(State.InCharacterSelect);
// Step 4: drain until CharacterList arrives. The transport sweep
// runs inside this blocking pump too (campaign landmine #8): the
// first server NAK can precede the first Tick().
// first server NAK can precede the first Tick(). This pump is also
// retail's cs_ConnectionRequestAcked resend window
// (ClientNet::ProcessConnection @ 0x00545450 case 0 at 0x0054547B):
// until the first decoded server packet confirms the connection, a
// lost ConnectResponse is re-sent every 0.333 s — without it, one
// dropped handshake datagram is a hang to the Connect deadline
// (the N5 loss decorator deliberately arms AFTER this window).
while (DateTime.UtcNow < deadline && Characters is null)
{
if (!_handshakeConfirmed
&& handshakeClock.GetTimestamp() - handshakeSentTimestamp
> handshakeRetryTicks)
{
_net.Send(_connectEndpoint, connectResponseDatagram);
handshakeSentTimestamp = handshakeClock.GetTimestamp();
}
PumpOnce();
SweepTransport();
}
@ -1514,6 +1570,19 @@ public sealed class WorldSession : IDisposable
// acceptance, before any heavy render-thread message handling.
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
// N6: the first checksum-valid post-negotiation packet confirms the
// server accepted our ConnectResponse and stops the handshake
// resend — retail's cs_ConnectionRequestAcked → cs_Connected edge
// (ClientNet::ProcessPacket @ 0x00545100: the 0x40000 ConnectRequest
// exclusion at 0x0054514E, SetConnectionState(..., 5) at
// 0x00545160). Frame-thread only, like every reader.
if (!_handshakeConfirmed
&& _transportNegotiated
&& !serverHeader.HasFlag(PacketHeaderFlags.ConnectRequest))
{
_handshakeConfirmed = true;
}
// 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