feat(net): N3 - AckNakScheduler, retail 2.0s cumulative ack replaces per-packet acks

Campaign N slice N3. Retail never acks per packet: SharedNet::EnqueuePak
@ 0x00543B10 is the binary's only AckSequence (0x4000) construction site,
gated at >= 2.0 s on ReceiverData::timeStamp_ (@ +0x10), armed at
connection birth by ReceiverData::Init @ 0x00548EF0, and arbitrated
NAK-xor-ack per sweep by ClientNet::ProcessConnection @ 0x00545450
(m_SeqIDsWeNAKed non-empty -> EnqueueNaks, else EnqueuePak;
SharedNet::EnqueueNaks @ 0x00543BD0 shares the SAME timestamp -
campaign landmine #7).

- New Transport/AckNakScheduler: owns the one shared timestamp; a
  non-empty NAK set suppresses the ack (N4 emits RequestRetransmit in
  that branch; in N3 it emits nothing - a documented transitional state,
  safe for exactly one slice on loopback), else ONE cleartext exact-flags
  AckSequence carrying the tracker's HighestIdReceived, header sequence
  borrowed from HighestIdSent without incrementing, 4-byte LE body.
  Flags are an EQUALITY, never an OR (landmine #5 - ACE's dedup
  exemption NetworkSession.cs:342-343 and watermark-skip :474-476 both
  require the exact value).
- ReliableTransport.Sweep pump order per FlowQueue::Empty @ 0x00548A20:
  interval clock, NAK/ack arbitration, pending resends, prune. The sweep
  already runs in Tick and both handshake pump loops (landmine #8), so
  cumulative acks flow during the character-list/enter-world floods at
  ACE's own ~2 s cadence.
- WorldSession: the Phase 4.9 per-packet reflex ack in ProcessDatagram
  and SendAck are DELETED; the [net-tick] acks/s probe now reads
  Stats.AcksSent; new internal TransportClockSource seam drives the
  2.0 s gate on virtual time in the conformance suite.
- N1 Fable-review advisory retired (Time-stamp fold-in): fresh reliable
  sends now stamp Header.Time = the current interval id, matching retail
  FlowQueue::TransmitNewPackets @ 0x00547A60 (header build at
  0x00547A84); resends already re-stamped. ACE never reads inbound
  Header.Time, so the wire stays compatible.

Tests: 723 Core.Net (7 new) - gate cadence + watermark-at-emission,
flags-equality pin + model acceptance at the reused sequence without a
watermark advance, NAK suppression and resume after the gap clears, a
50-packet CreateObject flood collapsing to ONE ack, the quiet-session
keepalive property across a 120 s virtual horizon (the reflex ack's
keepalive role, replaced and proven against ACE's 60 s TimeoutDeadline),
the Time fold-in, and a full FakeAceTransport lifecycle with zero
CRC/state/duplicate drops. Full solution Release: 9,744 passed /
5 skipped / 0 failed. Connected world-lifecycle gate PASS (capped +
uncapped-reconnect, graceful exits, 0 failures); canonical nine-stop
route PASS (0 failures).

Campaign section 9 N3 row updated (complete; SHA recorded at N4
kickoff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 13:51:57 +02:00
parent 19bfb8477d
commit 0265cc4236
9 changed files with 797 additions and 127 deletions

View file

@ -0,0 +1,168 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Transport;
/// <summary>
/// Campaign N Slice N3: 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
/// <c>AckSequence</c> (0x4000) construction site in the whole binary, and
/// it fires from this sweep alone.
///
/// <para>
/// The two branches are mutually exclusive on ONE shared timestamp
/// (<c>ReceiverData::timeStamp_</c> @ +0x10 — campaign landmine #7):
/// </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>
/// <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
/// <c>sharedTimestamp = now</c>.</item>
/// </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.
/// </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.
/// </para>
///
/// <para>
/// Single-threaded like the rest of the transport: <see cref="Sweep"/>
/// runs only from <see cref="ReliableTransport.Sweep"/> on the session's
/// frame thread.
/// </para>
/// </summary>
internal sealed class AckNakScheduler
{
/// <summary>Retail's cumulative-ack gate
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>, the x87 compare against
/// 2.0 at 0x00543B32).</summary>
public const double AckGateSeconds = 2.0;
private readonly InboundSequenceTracker _inbound;
private readonly OutboundFlowQueue _outbound;
private readonly TransportStats _stats;
private readonly DatagramSendDelegate _send;
private readonly ushort _sessionClientId;
private readonly long _ackGateTicks;
/// <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>
private long _sharedTimestamp;
public AckNakScheduler(
TransportClock clock,
InboundSequenceTracker inbound,
OutboundFlowQueue outbound,
ushort sessionClientId,
TransportStats stats,
DatagramSendDelegate send)
{
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(inbound);
ArgumentNullException.ThrowIfNull(outbound);
ArgumentNullException.ThrowIfNull(stats);
ArgumentNullException.ThrowIfNull(send);
_inbound = inbound;
_outbound = outbound;
_sessionClientId = sessionClientId;
_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.
_sharedTimestamp = clock.GetTimestamp();
}
/// <summary>
/// One per-frame arbitration pass (<c>ClientNet::ProcessConnection
/// @ 0x00545450</c>: <c>m_SeqIDsWeNAKed._currNum != 0 ? EnqueueNaks
/// : EnqueuePak</c>). <paramref name="now"/> is the transport clock's
/// current timestamp, sampled once by the caller.
/// </summary>
public void Sweep(long now)
{
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.
return;
}
// SharedNet::EnqueuePak @ 0x00543B10 — proceed when the elapsed
// time is NOT less than 2.0 (the `& 1` x87 status test at
// 0x00543B3D), i.e. now sharedTimestamp ≥ 2.0 s.
if (now - _sharedTimestamp < _ackGateTicks)
return;
EmitCumulativeAck();
_sharedTimestamp = now;
}
/// <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.
/// </summary>
private void EmitCumulativeAck()
{
Span<byte> datagram = stackalloc byte[PacketHeader.Size + sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(
datagram.Slice(PacketHeader.Size),
_inbound.HighestIdReceived);
var header = new PacketHeader
{
// Borrow the last reliable sequence without incrementing —
// the ack is not part of the reliable stream. ACE accepts it
// at the reused sequence via the exact-flags dedup exemption
// (NetworkSession.cs:342-343) and skips the watermark advance
// (:474-476).
Sequence = _outbound.HighestIdSent,
Flags = PacketHeaderFlags.AckSequence,
Id = _sessionClientId,
};
int datagramLength = PacketCodec.FinalizeInPlace(
header,
datagram,
bodyLength: sizeof(uint),
optionalLength: sizeof(uint),
outboundIsaac: null);
_send(datagram.Slice(0, datagramLength));
_stats.AcksSent++;
}
}

View file

@ -117,9 +117,13 @@ internal sealed class OutboundFlowQueue : IDisposable
/// Encode one game message as a single-fragment reliable packet, send
/// it, THEN cache it (retail commits to the sent-packet store only after
/// a successful send — <c>FlowQueue::TransmitNewPackets @ 0x00547C85</c>).
/// Wire shape is byte-identical to the pre-N1 <c>WorldSession</c> path:
/// flags <c>BlobFragments|EncryptedChecksum</c>, <c>Time</c>/<c>Iteration</c>
/// zero, session client id, one ISAAC word.
/// Flags <c>BlobFragments|EncryptedChecksum</c>, session client id, one
/// ISAAC word, and — the N3 fold-in of the N1 review advisory —
/// <c>Time</c> = the current interval id: retail stamps
/// <c>CurLocalInterval_.intervalID_</c> on every fresh packet
/// (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, the header build
/// at 0x00547A84). ACE never reads inbound <c>Header.Time</c>
/// (campaign §3), so wire compatibility is unaffected.
/// </summary>
public void SendGameMessage(
ReadOnlySpan<byte> gameMessageBody,
@ -142,6 +146,7 @@ internal sealed class OutboundFlowQueue : IDisposable
Flags = PacketHeaderFlags.BlobFragments
| PacketHeaderFlags.EncryptedChecksum,
Id = _sessionClientId,
Time = _clock.IntervalId,
};
int datagramLength = PacketCodec.FinalizeInPlace(
header,

View file

@ -6,19 +6,20 @@ 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), and the unconditional counters. The
/// <c>AckNakScheduler</c> joins in N3/N4 — until then ack behavior stays in
/// <c>WorldSession</c> untouched.
/// 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, 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),
/// gated on transport negotiation (ACE's <c>Session.CheckState</c> discards
/// early control traffic).
/// 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
@ -32,6 +33,11 @@ internal sealed class ReliableTransport : IDisposable
/// 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; }
public ReliableTransport(
@ -52,24 +58,34 @@ internal sealed class ReliableTransport : IDisposable
send,
pool);
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
Scheduler = new AckNakScheduler(
Clock,
Inbound,
Outbound,
sessionClientId,
Stats,
send);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
}
/// <summary>Last reliable sequence on the wire — the value unsequenced
/// control packets (the reflex ack) borrow without incrementing.</summary>
/// control packets (the cumulative ack) borrow without incrementing.</summary>
public uint HighestIdSent => Outbound.HighestIdSent;
/// <summary>
/// One transport pump: interval clock forward, pending NAKed resends
/// out, acked cache entries pruned. Pump order per retail
/// <c>FlowQueue::Empty @ 0x00548A20</c> (NAK consumption already
/// happened at receive time; retransmits precede 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).
/// 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.
/// </summary>
public void Sweep()
{
Clock.Update();
Scheduler.Sweep(Clock.GetTimestamp());
Outbound.TransmitPendingResends();
}

View file

@ -47,6 +47,11 @@ internal sealed class TransportStats
/// pre-draw) plus one per checksum-failure re-park.</summary>
public long KeysParked;
/// <summary>N3: cumulative <c>AckSequence</c> packets emitted by the
/// 2.0 s sweep (<c>SharedNet::EnqueuePak @ 0x00543B10</c> — retail's
/// only ack construction site; there is no per-packet ack).</summary>
public long AcksSent;
/// <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

@ -690,6 +690,16 @@ public sealed class WorldSession : IDisposable
/// conformance/loss suites. Null before negotiation.</summary>
internal ReliableTransport? Transport => _transport;
/// <summary>
/// N3 test seam: injectable monotonic source for the transport clock so
/// the conformance suite can drive the 2.0 s cumulative-ack gate (and
/// the 0.5 s interval counter) on virtual time. Must be set BEFORE
/// <see cref="Connect"/> (the transport is born there). Null →
/// production <see cref="Stopwatch"/> timing.
/// </summary>
internal (Func<long> GetTimestamp, long Frequency)? TransportClockSource
{ get; set; }
// Movement sequence counters — echoed back in every MoveToState and
// AutonomousPosition so the server can detect stale/reordered packets.
// Initialized from CreateObject PhysicsData timestamps, updated by
@ -890,19 +900,26 @@ public sealed class WorldSession : IDisposable
// generation into connection-level control packets, including the
// final disconnect. ACE currently emits iteration 1.
_sessionIteration = connectRequestIteration;
// N1+N2: the reliable transport is born at ISAAC-seeding time,
// owning BOTH keystreams. 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 — byte-identical to the pre-N1 wire behavior. Inbound:
// the tracker owns the server keystream, the received watermark,
// and the NAK set (campaign §2.2); its watermark starts 1 (see
// InboundSequenceTracker.AceInitialWatermark).
// N1+N2+N3: 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).
_transport = new ReliableTransport(
new IsaacRandom(clientSeedBytes),
new IsaacRandom(serverSeedBytes),
_sessionClientId,
datagram => _net.Send(datagram));
datagram => _net.Send(datagram),
clock: TransportClockSource is { } clockSource
? new TransportClock(
clockSource.GetTimestamp,
clockSource.Frequency)
: null);
_transportNegotiated = true;
// Publish only after the receiver identity and crypto state are fully
@ -1090,16 +1107,18 @@ public sealed class WorldSession : IDisposable
// #260 probe state — only touched when NetDiagnostics.ProbeNet is set.
// The inter-Tick gap doubles as a frame-stall witness: Tick runs once per
// frame on the frame thread, so a GC pause or saturated frame shows up
// directly as maxgap. _probeSendWindow/_probeAckWindow are Interlocked so
// a hypothetical off-thread send can't corrupt the window counters (the
// [net-out] tid field is what would prove such a send exists).
// directly as maxgap. _probeSendWindow is Interlocked so a hypothetical
// off-thread send can't corrupt the window counter (the [net-out] tid
// field is what would prove such a send exists). Acks are counted by the
// transport (Stats.AcksSent, incremented on the frame-thread sweep);
// _probeAckSeenTotal is the last cumulative value the probe printed.
private long _probeLastTickTs;
private long _probeWindowStartTs;
private long _probeMaxGapTicks;
private int _probeProcessedWindow;
private int _probeBudgetBreaks;
private int _probeSendWindow;
private int _probeAckWindow;
private long _probeAckSeenTotal;
// Probe-owned queue depth: the SingleReader channel's Reader.Count
// throws NotSupportedException, so the net thread increments on
// enqueue and the frame thread decrements on dequeue instead.
@ -1134,7 +1153,9 @@ public sealed class WorldSession : IDisposable
double windowSeconds = (double)windowTicks / Stopwatch.Frequency;
double maxGapMs = _probeMaxGapTicks * 1000.0 / Stopwatch.Frequency;
int sends = Interlocked.Exchange(ref _probeSendWindow, 0);
int acks = Interlocked.Exchange(ref _probeAckWindow, 0);
long ackTotal = _transport?.Stats.AcksSent ?? 0;
long acks = ackTotal - _probeAckSeenTotal;
_probeAckSeenTotal = ackTotal;
Console.WriteLine(
$"[net-tick] in/s={_probeProcessedWindow / windowSeconds:F0}"
+ $" q={Volatile.Read(ref _probeInboundDepth)}"
@ -1391,9 +1412,12 @@ public sealed class WorldSession : IDisposable
// acceptance, before any heavy render-thread message handling.
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
// N1: consume the transport control surfaces FIRST, before the
// reflex ack below (which still fires unchanged this slice; the
// AckNakScheduler replaces it in N3).
// 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
// (SharedNet::EnqueuePak @ 0x00543B10 is the binary's only 0x4000
// construction site). The AckNakScheduler emits ONE cumulative
// AckSequence per 2.0 s from the SweepTransport pump instead.
if (_transport is { } transport)
{
// Server NAK (RequestRetransmit 0x1000): merge the requested
@ -1426,19 +1450,6 @@ public sealed class WorldSession : IDisposable
transport.Outbound.OnAckSequence(packet.Optional.AckSequence);
}
// Phase 4.9: send an ACK_SEQUENCE control packet for every received
// server packet with sequence > 0 and no ACK flag of its own. This
// is the proper holtburger pattern (every received packet gets an
// ack queued back; not periodic). Without it, ACE drops the session
// with "Network Timeout" because it sees no acks coming back —
// which surfaces in other clients' views as the player rendering
// as a stationary purple haze (loading state).
if (serverHeader.Sequence > 0
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
{
SendAck(serverHeader.Sequence);
}
// Phase G.1: propagate TimeSync-flagged server time to anyone who
// needs it (sky/day-night lerp in particular). Server sends this
// periodically — no explicit opcode, just the header flag.
@ -2469,63 +2480,6 @@ public sealed class WorldSession : IDisposable
return false;
}
/// <summary>
/// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging
/// <paramref name="serverPacketSequence"/>. This is a cleartext control
/// packet (no EncryptedChecksum) — the body is just the 4-byte server
/// sequence number being acknowledged. The header re-uses the most
/// recently sent client sequence (no increment) because acks aren't
/// themselves part of the reliable stream the server tracks.
///
/// <para>
/// Without sending these, ACE drops the session with
/// <c>Network Timeout</c> after ~60s — and during that 60s the
/// character appears to other clients as a stationary purple haze
/// (loading state) because the server hasn't seen the client confirm
/// any post-EnterWorld traffic.
/// </para>
///
/// <para>
/// Pattern ported from
/// <c>references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack</c>
/// and the receive-side trigger at
/// <c>.../session/receive.rs::finalize_ordered_server_packet</c>.
/// </para>
/// </summary>
private void SendAck(uint serverPacketSequence)
{
// 4-byte body: little-endian u32 of the server sequence we're acking.
Span<byte> datagram = stackalloc byte[
PacketHeader.Size + sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(
datagram.Slice(PacketHeader.Size),
serverPacketSequence);
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
// ack headers. We mirror that — acks borrow the most recently issued
// client sequence (the transport's HighestIdSent) rather than
// consuming a new one. N1 keeps this behaviorally EXACTLY as-is;
// the AckNakScheduler arrives in N3.
uint ackHeaderSequence = _transport?.HighestIdSent ?? 0u;
var header = new PacketHeader
{
Sequence = ackHeaderSequence,
Flags = PacketHeaderFlags.AckSequence,
Id = _sessionClientId,
};
int datagramLength = PacketCodec.FinalizeInPlace(
header,
datagram,
bodyLength: sizeof(uint),
optionalLength: sizeof(uint),
outboundIsaac: null);
_net.Send(datagram.Slice(0, datagramLength));
if (NetDiagnostics.ProbeNet)
Interlocked.Increment(ref _probeAckWindow);
}
private void Transition(State next)
{
if (CurrentState == next) return;