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:
parent
3899ebe0fd
commit
f9c5e47e7f
10 changed files with 691 additions and 19 deletions
|
|
@ -1,3 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -16,19 +18,77 @@ namespace AcDream.Core.Net.Packets;
|
|||
/// the full message is released on the last fragment regardless of
|
||||
/// its index.</item>
|
||||
/// <item>Duplicate-fragment idempotence: receiving index N twice for the
|
||||
/// same Sequence is harmless — the second copy is silently ignored.</item>
|
||||
/// same Sequence is harmless — the second copy is silently ignored.
|
||||
/// A late duplicate of an ALREADY-COMPLETED message is dropped via
|
||||
/// the recently-completed ring below instead of allocating a fresh
|
||||
/// partial that could never complete.</item>
|
||||
/// <item>Single-fragment messages: Count=1 releases immediately on
|
||||
/// that one fragment with no buffering.</item>
|
||||
/// <item>Orphaned partials: if fragments for a Sequence arrive but the
|
||||
/// message never completes, they stay buffered until
|
||||
/// <see cref="DropAll"/> is called or the assembler is disposed.
|
||||
/// A future phase will add a TTL-based eviction.</item>
|
||||
/// <item>Orphaned partials (Campaign N Slice N6): entries whose last
|
||||
/// accepted fragment is older than <see cref="PartialTtlSeconds"/>
|
||||
/// are dropped by <see cref="SweepExpired"/>, which
|
||||
/// <see cref="Transport.ReliableTransport.Sweep"/> runs on a 5 s
|
||||
/// cadence. N4's RejectRetransmit abandonment made an unrecoverable
|
||||
/// partial a REACHABLE permanent state (the server pruned a
|
||||
/// fragment-bearing packet from its cache and told us to stop
|
||||
/// asking — that blob can never complete), so the pre-N6 "buffered
|
||||
/// until DropAll" posture was a slow leak on a lossy link.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle for the eviction shape: the client's ephemeral-blob info
|
||||
/// table is pruned on a 5.0 s sweep gate
|
||||
/// (<c>Indicator::FlushTimedOutEphInfo @ 0x0054A3D0</c>, the x87 compare
|
||||
/// against 5.0 at 0x0054A3DC), each entry timing out 5.0 s after its LAST
|
||||
/// refresh (<c>ArrivedEphInfo::fTimedOut @ 0x0054AE30</c>; the timestamp is
|
||||
/// re-stamped on every update, <c>ArrivedEphInfo::UpdateNetBlobID
|
||||
/// @ 0x0054AE00</c>). Our partial entries mirror the re-stamp-on-update
|
||||
/// rule; the 60 s TTL (vs retail's 5 s on its ordering-stamp table) and the
|
||||
/// completed-sequence ring are acdream adaptations — divergence register
|
||||
/// row AD-52. 60 s is a floor, not a tunable: a partial that is merely slow
|
||||
/// (packet-level NAK recovery in flight) must never be evicted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FragmentAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// AD-52: age floor before an incomplete partial is dropped, measured
|
||||
/// from its last ACCEPTED fragment. Any in-flight recovery (0.6 s NAK
|
||||
/// cadence, ACE's 120 s S2C cache) resolves orders of magnitude faster;
|
||||
/// only a server-abandoned partial (RejectRetransmit) can reach it.
|
||||
/// Do not shrink.
|
||||
/// </summary>
|
||||
internal const double PartialTtlSeconds = 60.0;
|
||||
|
||||
/// <summary>AD-52: how many recently-completed multi-fragment sequences
|
||||
/// are remembered to drop late duplicates without re-partialing.</summary>
|
||||
internal const int CompletedRingSize = 64;
|
||||
|
||||
private static double DefaultNowSeconds() =>
|
||||
(double)Stopwatch.GetTimestamp() / Stopwatch.Frequency;
|
||||
|
||||
private readonly Dictionary<uint, PartialMessage> _inFlight = new();
|
||||
private readonly Func<double> _nowSeconds;
|
||||
|
||||
// Ring of the last CompletedRingSize completed multi-fragment sequences.
|
||||
// _completedCount bounds the membership scan so the zero-initialized
|
||||
// slots can never match a real sequence 0 (ACE's fragment sequences
|
||||
// START at 0 — SessionConnectionData.cs:36).
|
||||
private readonly uint[] _completedSequences = new uint[CompletedRingSize];
|
||||
private int _completedNext;
|
||||
private int _completedCount;
|
||||
|
||||
public FragmentAssembler()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: injectable monotonic seconds source for the TTL
|
||||
/// stamps and <see cref="SweepExpired"/>. Production uses
|
||||
/// <see cref="Stopwatch"/> time.</summary>
|
||||
internal FragmentAssembler(Func<double>? nowSeconds) =>
|
||||
_nowSeconds = nowSeconds ?? DefaultNowSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Number of logical messages currently partially-assembled (waiting on
|
||||
|
|
@ -62,7 +122,12 @@ public sealed class FragmentAssembler
|
|||
// its own inbound assembler keys on Sequence for the same reason.
|
||||
if (!_inFlight.TryGetValue(h.Sequence, out var partial))
|
||||
{
|
||||
partial = new PartialMessage(h.Count, h.Queue);
|
||||
// N6: a late duplicate of an already-completed message must not
|
||||
// allocate a fresh partial that can never complete.
|
||||
if (WasRecentlyCompleted(h.Sequence))
|
||||
return null;
|
||||
|
||||
partial = new PartialMessage(h.Count, h.Queue, _nowSeconds());
|
||||
_inFlight[h.Sequence] = partial;
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +136,9 @@ public sealed class FragmentAssembler
|
|||
{
|
||||
partial.Fragments[h.Index] = fragment.Payload;
|
||||
partial.ReceivedCount++;
|
||||
// Retail re-stamps on update (ArrivedEphInfo::UpdateNetBlobID
|
||||
// @ 0x0054AE00): a slow-but-alive partial never ages out.
|
||||
partial.LastFragmentSeconds = _nowSeconds();
|
||||
}
|
||||
|
||||
if (partial.ReceivedCount < partial.TotalFragments)
|
||||
|
|
@ -91,6 +159,7 @@ public sealed class FragmentAssembler
|
|||
}
|
||||
|
||||
_inFlight.Remove(h.Sequence);
|
||||
RememberCompleted(h.Sequence);
|
||||
messageQueue = partial.Queue;
|
||||
return combined;
|
||||
}
|
||||
|
|
@ -121,9 +190,16 @@ public sealed class FragmentAssembler
|
|||
header.Sequence,
|
||||
out PartialMessage? partial))
|
||||
{
|
||||
// N6: drop a late duplicate of an already-completed message
|
||||
// instead of re-partialing it (the pre-N6 leak: the fresh
|
||||
// partial could never complete and lived forever).
|
||||
if (WasRecentlyCompleted(header.Sequence))
|
||||
return false;
|
||||
|
||||
partial = new PartialMessage(
|
||||
header.Count,
|
||||
header.Queue);
|
||||
header.Queue,
|
||||
_nowSeconds());
|
||||
_inFlight[header.Sequence] = partial;
|
||||
}
|
||||
else if (partial.TotalFragments != header.Count
|
||||
|
|
@ -139,6 +215,7 @@ public sealed class FragmentAssembler
|
|||
partial.Fragments[header.Index] =
|
||||
fragment.Payload.ToArray();
|
||||
partial.ReceivedCount++;
|
||||
partial.LastFragmentSeconds = _nowSeconds();
|
||||
}
|
||||
|
||||
if (partial.ReceivedCount < partial.TotalFragments)
|
||||
|
|
@ -164,14 +241,64 @@ public sealed class FragmentAssembler
|
|||
}
|
||||
|
||||
_inFlight.Remove(header.Sequence);
|
||||
RememberCompleted(header.Sequence);
|
||||
message = combined;
|
||||
messageQueue = partial.Queue;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N6 age-based eviction: drop every partial whose last accepted
|
||||
/// fragment is older than <see cref="PartialTtlSeconds"/>. Called by
|
||||
/// <see cref="Transport.ReliableTransport.Sweep"/> on the retail 5 s
|
||||
/// flush cadence (<c>Indicator::FlushTimedOutEphInfo @ 0x0054A3D0</c>).
|
||||
/// Returns the number of partials evicted.
|
||||
/// </summary>
|
||||
internal int SweepExpired()
|
||||
{
|
||||
if (_inFlight.Count == 0)
|
||||
return 0;
|
||||
|
||||
double now = _nowSeconds();
|
||||
int evicted = 0;
|
||||
foreach ((uint sequence, PartialMessage partial) in _inFlight)
|
||||
{
|
||||
// Strictly-older-than the floor: an entry exactly 60 s old
|
||||
// survives (an eviction floor, never an eager cutoff).
|
||||
if (now - partial.LastFragmentSeconds > PartialTtlSeconds)
|
||||
{
|
||||
// Dictionary.Remove during enumeration is safe on .NET
|
||||
// Core 3.0+ and does not invalidate the enumerator.
|
||||
_inFlight.Remove(sequence);
|
||||
evicted++;
|
||||
}
|
||||
}
|
||||
|
||||
return evicted;
|
||||
}
|
||||
|
||||
/// <summary>Discard all in-flight partial messages.</summary>
|
||||
public void DropAll() => _inFlight.Clear();
|
||||
|
||||
private bool WasRecentlyCompleted(uint sequence)
|
||||
{
|
||||
for (int i = 0; i < _completedCount; i++)
|
||||
{
|
||||
if (_completedSequences[i] == sequence)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RememberCompleted(uint sequence)
|
||||
{
|
||||
_completedSequences[_completedNext] = sequence;
|
||||
_completedNext = (_completedNext + 1) % CompletedRingSize;
|
||||
if (_completedCount < CompletedRingSize)
|
||||
_completedCount++;
|
||||
}
|
||||
|
||||
private sealed class PartialMessage
|
||||
{
|
||||
public readonly byte[]?[] Fragments;
|
||||
|
|
@ -179,11 +306,17 @@ public sealed class FragmentAssembler
|
|||
public readonly ushort Queue;
|
||||
public int ReceivedCount;
|
||||
|
||||
public PartialMessage(int count, ushort queue)
|
||||
/// <summary>Seconds stamp of the last ACCEPTED fragment (creation
|
||||
/// stamp until one lands) — the TTL clock for
|
||||
/// <see cref="SweepExpired"/>.</summary>
|
||||
public double LastFragmentSeconds;
|
||||
|
||||
public PartialMessage(int count, ushort queue, double nowSeconds)
|
||||
{
|
||||
TotalFragments = count;
|
||||
Fragments = new byte[count][];
|
||||
Queue = queue;
|
||||
LastFragmentSeconds = nowSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Buffers;
|
||||
using AcDream.Core.Net.Cryptography;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
|
|
@ -40,6 +41,19 @@ internal sealed class ReliableTransport : IDisposable
|
|||
|
||||
public TransportStats Stats { get; }
|
||||
|
||||
/// <summary>
|
||||
/// N6: retail's ephemeral-info flush cadence
|
||||
/// (<c>Indicator::FlushTimedOutEphInfo @ 0x0054A3D0</c>, the x87 compare
|
||||
/// against 5.0 at 0x0054A3DC) — how often the sweep asks the fragment
|
||||
/// assembler to evict aged partials. The per-entry TTL itself lives in
|
||||
/// <see cref="FragmentAssembler.PartialTtlSeconds"/> (AD-52).
|
||||
/// </summary>
|
||||
public const double AssemblerSweepSeconds = 5.0;
|
||||
|
||||
private readonly FragmentAssembler? _assembler;
|
||||
private readonly long _assemblerSweepTicks;
|
||||
private long _assemblerSweepTimestamp;
|
||||
|
||||
public ReliableTransport(
|
||||
IsaacRandom outboundIsaac,
|
||||
IsaacRandom inboundIsaac,
|
||||
|
|
@ -47,10 +61,16 @@ internal sealed class ReliableTransport : IDisposable
|
|||
ushort sessionIteration,
|
||||
DatagramSendDelegate send,
|
||||
TransportClock? clock = null,
|
||||
ArrayPool<byte>? pool = null)
|
||||
ArrayPool<byte>? pool = null,
|
||||
FragmentAssembler? assembler = null)
|
||||
{
|
||||
Clock = clock ?? new TransportClock();
|
||||
Stats = new TransportStats();
|
||||
_assembler = assembler;
|
||||
// Same defensive rounding as the scheduler gates (N4 review F1).
|
||||
_assemblerSweepTicks =
|
||||
(long)Math.Round(AssemblerSweepSeconds * Clock.Frequency);
|
||||
_assemblerSweepTimestamp = Clock.GetTimestamp();
|
||||
Outbound = new OutboundFlowQueue(
|
||||
outboundIsaac,
|
||||
sessionClientId,
|
||||
|
|
@ -91,8 +111,19 @@ internal sealed class ReliableTransport : IDisposable
|
|||
public void Sweep()
|
||||
{
|
||||
Clock.Update();
|
||||
Scheduler.Sweep(Clock.GetTimestamp());
|
||||
long now = Clock.GetTimestamp();
|
||||
Scheduler.Sweep(now);
|
||||
Outbound.TransmitPendingResends();
|
||||
|
||||
// N6: age out abandoned fragment partials on retail's 5 s flush
|
||||
// cadence (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0 — re-stamp
|
||||
// the flush clock, then walk the table dropping timed-out entries).
|
||||
if (_assembler is not null
|
||||
&& now - _assemblerSweepTimestamp >= _assemblerSweepTicks)
|
||||
{
|
||||
_assemblerSweepTimestamp = now;
|
||||
_assembler.SweepExpired();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns every rented cache buffer to the pool.</summary>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue