feat(net): N1 - outbound sent-packet cache + resend on NAK

Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.

New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
  counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
  `intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
  stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
  @ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
  optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
  @ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
  pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
  @ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
  pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
  store, the wrap-safe sorted dedup pending-resend list
  (FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
  Cache commit happens AFTER a successful send
  (FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
  NAK ids[0] folds into the watermark as retail's implicit cumulative ack
  (RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
  20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
  with fragments), Time = current interval id, Sequence/Id/Iteration/
  DataSize verbatim, checksum = fresh header hash + stored sealed checksum
  (FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
  original ISAAC key rides inside the sealed value - no new keystream word
  is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
  path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
  prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
  this slice.
- TransportStats: unconditional counters (ResendsSent,
  NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.

PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.

WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.

Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.

N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.

Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.

Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 12:21:37 +02:00
parent e395861053
commit 43e60a6971
13 changed files with 1491 additions and 65 deletions

View file

@ -8,6 +8,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net;
@ -66,9 +67,10 @@ internal sealed class NetClientWorldSessionTransport(IPEndPoint remote)
/// </code>
///
/// <para>
/// <b>Still deferred:</b> retransmit handling and unsolicited-disconnect
/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout
/// are live.
/// <b>Still deferred:</b> inbound sequence-aligned ISAAC + client NAK
/// emission (Campaign N slices N2/N4) and unsolicited-disconnect recovery.
/// The outbound sent-packet cache + resend on server NAK (N1), ACKs, world
/// updates, chat, and retail-ordered graceful logout are live.
/// </para>
/// </summary>
public sealed class WorldSession : IDisposable
@ -672,12 +674,22 @@ public sealed class WorldSession : IDisposable
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
private IsaacRandom? _inboundIsaac;
private IsaacRandom? _outboundIsaac;
private ushort _sessionClientId;
private ushort _sessionIteration;
private bool _transportNegotiated;
private uint _clientPacketSequence;
private uint _fragmentSequence = 1;
/// <summary>
/// Campaign N Slice N1: the reliable outbound transport — outbound
/// ISAAC, packet/fragment sequences, sent-packet cache, resend on NAK.
/// Constructed at ISAAC-seeding time in <see cref="Connect"/>; null
/// before negotiation (reliable sends are impossible then anyway — the
/// keystream does not exist yet).
/// </summary>
private ReliableTransport? _transport;
/// <summary>Test seam: transport counters + cache depth for the
/// conformance/loss suites. Null before negotiation.</summary>
internal ReliableTransport? Transport => _transport;
// Movement sequence counters — echoed back in every MoveToState and
// AutonomousPosition so the server can detect stale/reordered packets.
@ -858,14 +870,22 @@ public sealed class WorldSession : IDisposable
byte[] clientSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
_inboundIsaac = new IsaacRandom(serverSeedBytes);
_outboundIsaac = new IsaacRandom(clientSeedBytes);
_sessionClientId = (ushort)opt.ConnectRequestClientId;
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
// generation into connection-level control packets, including the
// final disconnect. ACE currently emits iteration 1.
_sessionIteration = connectRequestIteration;
// N1: the reliable transport is born at ISAAC-seeding time, owning
// the outbound keystream + packet/fragment sequences the session
// used to hold directly. 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.
_transport = new ReliableTransport(
new IsaacRandom(clientSeedBytes),
_sessionClientId,
datagram => _net.Send(datagram));
_transportNegotiated = true;
_clientPacketSequence = 2;
// Publish only after the receiver identity and crypto state are fully
// committed. A synchronous App callback may throw or request teardown;
@ -881,10 +901,13 @@ public sealed class WorldSession : IDisposable
Transition(State.InCharacterSelect);
// Step 4: drain until CharacterList arrives
// 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().
while (DateTime.UtcNow < deadline && Characters is null)
{
PumpOnce();
SweepTransport();
}
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
}
@ -909,11 +932,15 @@ public sealed class WorldSession : IDisposable
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
// Wait for CharacterEnterWorldServerReady (0xF7DF)
// Wait for CharacterEnterWorldServerReady (0xF7DF). Sweep inside
// the blocking pump (campaign landmine #8): the EnterWorld
// CreateObject flood — and any NAK it provokes — precedes the
// first Tick().
bool serverReady = false;
while (DateTime.UtcNow < deadline && !serverReady)
{
var drained = PumpOnce(out var opcodes);
SweepTransport();
if (!drained) continue;
foreach (var op in opcodes)
if (op == 0xF7DFu) { serverReady = true; break; }
@ -1020,9 +1047,28 @@ public sealed class WorldSession : IDisposable
}
if (NetDiagnostics.ProbeNet)
ProbeNetTickCadence(start, processed, budgetBroke);
// N1: the transport sweep runs at the end of EVERY Tick, after the
// budget break — a deferred inbound tail must not defer a due
// resend past this frame.
SweepTransport();
return processed;
}
/// <summary>
/// N1: 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.
/// </summary>
private void SweepTransport()
{
if (!_transportNegotiated)
return;
_transport?.Sweep();
}
// #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
@ -1257,6 +1303,31 @@ public sealed class WorldSession : IDisposable
// acceptance, before any heavy render-thread message handling.
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
PacketHeader serverHeader = dec.Packet.Header;
// N1: consume the transport control surfaces FIRST, before the
// reflex ack below (which still fires unchanged this slice; the
// AckNakScheduler replaces it in N3).
if (_transport is { } transport)
{
// Server NAK (RequestRetransmit 0x1000): merge the requested
// ids into the pending-resend list; ids[0] doubles as retail's
// implicit cumulative ack (RecipientData::ProcessNaks
// @ 0x00547010). The resends go out on the next sweep.
if ((serverHeader.Flags & PacketHeaderFlags.RequestRetransmit) != 0
&& dec.Packet.Optional.RetransmitRequestCount > 0)
{
transport.Outbound.OnRetransmitRequest(
dec.Packet.Optional.RetransmitRequestBytes.Span,
dec.Packet.Optional.RetransmitRequestCount);
}
// Cumulative ack (AckSequence 0x4000): wrap-safe max into the
// watermark; the cache prunes strictly below it on the sweep.
if ((serverHeader.Flags & PacketHeaderFlags.AckSequence) != 0)
transport.Outbound.OnAckSequence(dec.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
@ -1264,7 +1335,6 @@ public sealed class WorldSession : IDisposable
// 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).
PacketHeader serverHeader = dec.Packet.Header;
if (serverHeader.Sequence > 0
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
{
@ -2242,28 +2312,16 @@ public sealed class WorldSession : IDisposable
ProbeNetLogOutbound(gameMessageBody, queue);
try
{
Span<byte> datagram = stackalloc byte[
PacketHeader.Size
+ MessageFragmentHeader.MaxFragmentSize];
int fragmentLength =
GameMessageFragment.WriteSingleFragment(
datagram.Slice(PacketHeader.Size),
_fragmentSequence++,
queue,
gameMessageBody);
var header = new PacketHeader
{
Sequence = _clientPacketSequence++,
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Id = _sessionClientId,
};
int datagramLength = PacketCodec.FinalizeInPlace(
header,
datagram,
fragmentLength,
optionalLength: 0,
_outboundIsaac);
_net.Send(datagram.Slice(0, datagramLength));
// N1: the reliable transport owns encode + send + cache. Wire
// shape is unchanged; the datagram is additionally cached for
// resend on server NAK. Pre-negotiation reliable sends were
// always impossible (no outbound keystream existed) — the
// exception simply names the state now.
ReliableTransport transport = _transport
?? throw new InvalidOperationException(
"reliable send before transport negotiation — "
+ "Connect() must seed ISAAC first");
transport.Outbound.SendGameMessage(gameMessageBody, queue);
}
catch (Exception ex) when (ProbeNetLogOutboundFault(ex))
{
@ -2295,8 +2353,10 @@ public sealed class WorldSession : IDisposable
detail = $" act=0x{act:X4} gseq={gseq}";
}
Console.WriteLine(
$"[net-out] op=0x{op:X4}{detail} q={queue} fseq={_fragmentSequence}"
+ $" pseq={_clientPacketSequence} len={body.Length}"
$"[net-out] op=0x{op:X4}{detail} q={queue}"
+ $" fseq={_transport?.Outbound.FragmentSequence ?? 0}"
+ $" pseq={_transport?.Outbound.PeekNextPacketSequence ?? 0}"
+ $" len={body.Length}"
+ $" tid={Environment.CurrentManagedThreadId} st={CurrentState}");
}
@ -2345,10 +2405,10 @@ public sealed class WorldSession : IDisposable
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
// ack headers. We mirror that — acks borrow the most recently issued
// client sequence rather than consuming a new one.
uint ackHeaderSequence = _clientPacketSequence > 0
? _clientPacketSequence - 1
: 0u;
// 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
{
@ -2433,6 +2493,9 @@ public sealed class WorldSession : IDisposable
}
_netCancel.Dispose();
// N1: return every rented sent-packet cache buffer before the
// socket goes away.
_transport?.Dispose();
_net.Dispose();
Transition(State.Disconnected);
}