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>
131 lines
5.3 KiB
C#
131 lines
5.3 KiB
C#
using System.Buffers;
|
|
using AcDream.Core.Net.Cryptography;
|
|
using AcDream.Core.Net.Packets;
|
|
|
|
namespace AcDream.Core.Net.Transport;
|
|
|
|
/// <summary>
|
|
/// Composition root for the session's reliable transport (campaign doc §4):
|
|
/// one <see cref="TransportClock"/>, the outbound flow queue (N1), the
|
|
/// inbound sequence tracker (N2), the ack/NAK scheduler (N3), and the
|
|
/// unconditional counters.
|
|
///
|
|
/// <para>
|
|
/// <see cref="Sweep"/> is the once-per-frame pump slice retail runs from
|
|
/// <c>Client::UseTime @ 0x00411C40</c> →
|
|
/// <c>PacketController::UseTime @ 0x005410D0</c>: advance the interval
|
|
/// clock, arbitrate NAK-xor-ack, serve pending retransmits, prune the acked
|
|
/// cache. The session calls it at the end of <c>Tick()</c> AND inside the
|
|
/// blocking handshake pump loops (landmine #8 — the EnterWorld flood
|
|
/// precedes the first Tick; ACE needs acks during the character-list /
|
|
/// enter-world floods, and the scheduler's ~2 s cadence there matches ACE's
|
|
/// own), gated on transport negotiation (ACE's <c>Session.CheckState</c>
|
|
/// discards early control traffic).
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class ReliableTransport : IDisposable
|
|
{
|
|
public TransportClock Clock { get; }
|
|
|
|
public OutboundFlowQueue Outbound { get; }
|
|
|
|
/// <summary>N2: the inbound sequence tracker — inbound ISAAC,
|
|
/// <c>highestIDReceived_</c>, and the NAK set. Born beside the outbound
|
|
/// queue at ISAAC-seeding time so both keystreams share one owner.</summary>
|
|
public InboundSequenceTracker Inbound { get; }
|
|
|
|
/// <summary>N3: retail's NAK-xor-ack sweep arbitration on the one
|
|
/// shared timestamp (<c>ClientNet::ProcessConnection @ 0x00545450</c>);
|
|
/// owns the 2.0 s cumulative <c>AckSequence</c>.</summary>
|
|
public AckNakScheduler Scheduler { get; }
|
|
|
|
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,
|
|
ushort sessionClientId,
|
|
ushort sessionIteration,
|
|
DatagramSendDelegate send,
|
|
TransportClock? clock = 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,
|
|
sessionIteration,
|
|
Clock,
|
|
Stats,
|
|
send,
|
|
pool);
|
|
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
|
|
Scheduler = new AckNakScheduler(
|
|
Clock,
|
|
Inbound,
|
|
Outbound,
|
|
sessionClientId,
|
|
sessionIteration,
|
|
Stats,
|
|
send);
|
|
Stats.CacheDepthSource = () => Outbound.CacheDepth;
|
|
}
|
|
|
|
/// <summary>Last reliable sequence on the wire — the value unsequenced
|
|
/// control packets (the cumulative ack) borrow without incrementing.</summary>
|
|
public uint HighestIdSent => Outbound.HighestIdSent;
|
|
|
|
/// <summary>
|
|
/// One transport pump: interval clock forward, NAK-xor-ack arbitration,
|
|
/// 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()
|
|
{
|
|
Clock.Update();
|
|
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>
|
|
public void Dispose() => Outbound.Dispose();
|
|
}
|