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

@ -561,7 +561,35 @@ public static class PacketCodec
Span<byte> datagram,
int bodyLength,
int optionalLength,
IsaacRandom? outboundIsaac)
IsaacRandom? outboundIsaac) =>
FinalizeInPlace(
header,
datagram,
bodyLength,
optionalLength,
outboundIsaac,
out _,
out _);
/// <summary>
/// <see cref="FinalizeInPlace(PacketHeader, Span{byte}, int, int, IsaacRandom?)"/>
/// plus the two values the sent-packet cache needs for a header-rebuilt
/// resend (Campaign N §2.1): <paramref name="isaacKeyUsed"/> is the
/// keystream word this encode consumed (0 for cleartext), and
/// <paramref name="sealedChecksum"/> is the checksum value BEFORE the
/// header hash is added — retail <c>NetPacket::checksum_</c>:
/// <c>payloadHash</c> for cleartext, <c>isaacKey ^ payloadHash</c> for
/// encrypted. A resend recomputes only the header hash and adds this
/// stored sealed value, reusing the original key (landmines #1/#2).
/// </summary>
internal static int FinalizeInPlace(
PacketHeader header,
Span<byte> datagram,
int bodyLength,
int optionalLength,
IsaacRandom? outboundIsaac,
out uint isaacKeyUsed,
out uint sealedChecksum)
{
if ((uint)bodyLength > ushort.MaxValue)
{
@ -602,14 +630,16 @@ public static class PacketCodec
}
uint isaacKey = outboundIsaac.Next();
header.Checksum =
headerHash + (isaacKey ^ payloadHash);
isaacKeyUsed = isaacKey;
sealedChecksum = isaacKey ^ payloadHash;
}
else
{
header.Checksum = headerHash + payloadHash;
isaacKeyUsed = 0;
sealedChecksum = payloadHash;
}
header.Checksum = headerHash + sealedChecksum;
header.Pack(datagram);
return datagramLength;
}

View file

@ -0,0 +1,310 @@
using System.Buffers;
using System.Buffers.Binary;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Transport;
/// <summary>Sends one finalized datagram to the wire. Span-shaped so the
/// send path stays allocation-free.</summary>
internal delegate void DatagramSendDelegate(ReadOnlySpan<byte> datagram);
/// <summary>
/// The outbound half of retail's reliable transport
/// (<c>RecipientData</c> + <c>ClientFlowQueue</c> + <c>SentPacketStore</c>
/// under <c>PacketController</c>): owns the outbound ISAAC keystream, the
/// reliable packet sequence (<c>highestIDSent_</c>), the fragment sequence,
/// the sent-packet cache, the pending-resend id list, and the cumulative-ack
/// watermark (<c>flushNum_</c>).
///
/// <para>Ported rules (campaign doc §2.1):</para>
/// <list type="bullet">
/// <item>Every reliable packet is cached AFTER a successful send
/// (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, cache commit at
/// <c>0x00547C85</c> → <c>SentPacketStore::AddSentPacket @ 0x0054AB00</c>).</item>
/// <item>Server <c>RequestRetransmit</c> ids merge-insert wrap-safe sorted
/// with dedup (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c>); <c>ids[0]</c>
/// doubles as an implicit cumulative ack
/// (<c>RecipientData::ProcessNaks @ 0x00547010</c>).</item>
/// <item>A resend re-emits the cached body with a REBUILT 20-byte header —
/// flags <c>Retransmission|EncryptedChecksum</c> (plus <c>BlobFragments</c>
/// when the body has fragments), <c>Time</c> = the CURRENT interval id,
/// <c>Sequence</c>/<c>DataSize</c> verbatim, checksum = fresh header hash +
/// stored sealed checksum (<c>FlowQueue::TransmitAcks @ 0x005485B0</c> /
/// <c>DequeueAck @ 0x005472F0</c>). The original ISAAC key is reused via
/// the stored sealed checksum — never a new keystream word
/// (<c>CryptoSystem::EncryptData @ 0x0065FF40</c>, non-null key path;
/// campaign landmines #1/#2).</item>
/// <item><c>AckSequence</c> folds wrap-safe max into the watermark; the
/// cache prunes STRICTLY older (<c>SentPacketStore::Flush @ 0x0054ACD0</c>).
/// No timer-based resend exists — resend only on explicit NAK (landmine #3).</item>
/// <item>Sequence allocation: <c>highestIDSent_</c> starts 1, pre-increment,
/// wrap 0xFFFFFFFF → 1 (never 0).</item>
/// </list>
///
/// <para>
/// Single-threaded by design (the ISAAC keystream is order-sensitive):
/// every member runs on the session's frame thread, matching the existing
/// <c>WorldSession</c> send discipline.
/// </para>
/// </summary>
internal sealed class OutboundFlowQueue : IDisposable
{
private readonly IsaacRandom _outboundIsaac;
private readonly TransportClock _clock;
private readonly TransportStats _stats;
private readonly DatagramSendDelegate _send;
private readonly SentPacketStore _store;
private readonly ArrayPool<byte> _pool;
private readonly ushort _sessionClientId;
/// <summary>Wrap-safe sorted pending NAKed ids awaiting the next sweep
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c> merge-insert).</summary>
private readonly List<uint> _pendingResends = new();
/// <summary>Retail <c>highestIDSent_</c> — the last reliable sequence
/// put on the wire. Starts 1 (the ConnectResponse holds sequence 1), so
/// the first reliable packet after the handshake is sequence 2.</summary>
public uint HighestIdSent { get; private set; }
/// <summary>The fragment sequence the NEXT reliable message will use.
/// Starts 1, exactly like the pre-N1 <c>WorldSession</c> field.</summary>
public uint FragmentSequence { get; private set; }
/// <summary>Retail <c>flushNum_</c> — the wrap-safe cumulative-ack
/// watermark; the cache holds everything at or above it.</summary>
public uint AckWatermark { get; private set; }
public int CacheDepth => _store.Count;
public int PendingResendCount => _pendingResends.Count;
public OutboundFlowQueue(
IsaacRandom outboundIsaac,
ushort sessionClientId,
TransportClock clock,
TransportStats stats,
DatagramSendDelegate send,
ArrayPool<byte>? pool = null,
uint highestIdSent = 1,
uint fragmentSequence = 1)
{
ArgumentNullException.ThrowIfNull(outboundIsaac);
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(stats);
ArgumentNullException.ThrowIfNull(send);
_outboundIsaac = outboundIsaac;
_sessionClientId = sessionClientId;
_clock = clock;
_stats = stats;
_send = send;
_pool = pool ?? ArrayPool<byte>.Shared;
_store = new SentPacketStore(_pool);
HighestIdSent = highestIdSent;
FragmentSequence = fragmentSequence;
}
/// <summary>The sequence the next reliable packet will carry —
/// pre-increment with retail's 0xFFFFFFFF → 1 wrap (never 0).</summary>
public uint PeekNextPacketSequence => NextSequenceAfter(HighestIdSent);
private static uint NextSequenceAfter(uint sequence) =>
sequence == uint.MaxValue ? 1u : sequence + 1u;
/// <summary>
/// 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.
/// </summary>
public void SendGameMessage(
ReadOnlySpan<byte> gameMessageBody,
GameMessageGroup queue)
{
byte[] buffer = _pool.Rent(
PacketHeader.Size
+ MessageFragmentHeader.Size
+ gameMessageBody.Length);
try
{
int fragmentLength = GameMessageFragment.WriteSingleFragment(
buffer.AsSpan(PacketHeader.Size),
FragmentSequence,
queue,
gameMessageBody);
var header = new PacketHeader
{
Sequence = PeekNextPacketSequence,
Flags = PacketHeaderFlags.BlobFragments
| PacketHeaderFlags.EncryptedChecksum,
Id = _sessionClientId,
};
int datagramLength = PacketCodec.FinalizeInPlace(
header,
buffer,
fragmentLength,
optionalLength: 0,
_outboundIsaac,
out uint isaacKeyUsed,
out uint sealedChecksum);
// The encode succeeded: the keystream word is drawn, so the
// sequence pair is committed even if the UDP send below faults
// (retail requeues from the head instead; register TS-61 —
// effectively unreachable on a connectionless socket).
FragmentSequence++;
HighestIdSent = header.Sequence;
_send(buffer.AsSpan(0, datagramLength));
_store.Add(
new SentPacketStore.CachedPacket(
header.Sequence,
buffer,
fragmentLength,
sealedChecksum,
isaacKeyUsed,
hasFragments: true),
optionalLength: 0);
}
catch
{
_pool.Return(buffer);
throw;
}
}
/// <summary>
/// Consume an inbound cumulative ack: wrap-safe max into the watermark
/// (<c>RecipientData::ProcessNaks @ 0x00547010</c> fold shape). Pruning
/// happens on the next sweep.
/// </summary>
public void OnAckSequence(uint ackSequence)
{
_stats.AcksConsumed++;
AckWatermark = SequenceMath.Max(AckWatermark, ackSequence);
}
/// <summary>
/// Consume an inbound <c>RequestRetransmit</c> id list (raw
/// little-endian u32 ids, count entries — the borrowed optional header's
/// <c>RetransmitRequestBytes</c>/<c>RetransmitRequestCount</c> pair).
/// Cached ids merge-insert into the pending-resend list
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c>); ids no longer cached
/// are dropped silently and counted — retail answers
/// <c>RejectRetransmit</c> (divergence register TS-57). <c>ids[0]</c>
/// also folds into the ack watermark as retail's implicit cumulative ack
/// (<c>RecipientData::ProcessNaks @ 0x00547010</c>).
/// </summary>
public void OnRetransmitRequest(ReadOnlySpan<byte> idBytes, int count)
{
if (count <= 0 || idBytes.Length < count * 4)
return;
_stats.NakRequestsReceived++;
for (int i = 0; i < count; i++)
{
uint id = BinaryPrimitives.ReadUInt32LittleEndian(
idBytes.Slice(i * 4));
if (i == 0)
OnAckSequence(id);
if (_store.Contains(id))
MergeInsertPending(id);
else
_stats.UncachedNakIds++;
}
}
/// <summary>Wrap-safe sorted insert with dedup
/// (<c>FlowQueue::EnqueueAcks @ 0x005488E0</c>).</summary>
private void MergeInsertPending(uint id)
{
int index = 0;
while (index < _pendingResends.Count
&& SequenceMath.IsNewer(id, _pendingResends[index]))
{
index++;
}
if (index < _pendingResends.Count && _pendingResends[index] == id)
return;
_pendingResends.Insert(index, id);
}
/// <summary>
/// The per-sweep resend pass (<c>FlowQueue::TransmitAcks @ 0x005485B0</c>
/// / <c>DequeueAck @ 0x005472F0</c>): serve every pending NAKed id in
/// ascending wrap-safe order with a rebuilt header, then prune the cache
/// strictly below the watermark. Resend happens ONLY here, only for
/// explicitly NAKed ids (landmine #3 — never resend unrequested).
/// </summary>
public void TransmitPendingResends()
{
if (_pendingResends.Count > 0)
{
for (int i = 0; i < _pendingResends.Count; i++)
{
// A pending id can leave the cache between NAK arrival and
// this sweep only if a newer ack already covered it — the
// server has it; serving nothing is correct.
if (!_store.TryGet(
_pendingResends[i],
out SentPacketStore.CachedPacket cached))
{
continue;
}
Resend(in cached);
}
_pendingResends.Clear();
}
_store.FlushOlderThan(AckWatermark);
}
private void Resend(in SentPacketStore.CachedPacket cached)
{
PacketHeader header = BuildResendHeader(in cached, _clock.IntervalId);
header.Pack(cached.Buffer);
_send(cached.Buffer.AsSpan(
0,
PacketHeader.Size + cached.BodyLength));
_stats.ResendsSent++;
}
/// <summary>
/// Rebuild the 20-byte resend header (campaign §2.1 / landmine #1 —
/// resends are NOT byte-identical): flags become exactly
/// <c>Retransmission|EncryptedChecksum</c> (=3; |<c>BlobFragments</c>
/// =7 with fragments), <c>Time</c> advances to the current interval id
/// (retail behavior; ACE ignores inbound Time), <c>Sequence</c>/
/// <c>Id</c>/<c>Iteration</c>/<c>DataSize</c> stay verbatim, and the
/// checksum is the FRESH header hash plus the stored sealed checksum —
/// the original ISAAC key rides along inside the sealed value, so no
/// new keystream word is ever drawn (landmine #2).
/// </summary>
internal static PacketHeader BuildResendHeader(
in SentPacketStore.CachedPacket cached,
ushort intervalId)
{
PacketHeader header = PacketHeader.Unpack(cached.Buffer);
PacketHeaderFlags flags = PacketHeaderFlags.Retransmission
| PacketHeaderFlags.EncryptedChecksum;
if (cached.HasFragments)
flags |= PacketHeaderFlags.BlobFragments;
header.Flags = flags;
header.Time = intervalId;
header.Checksum =
header.CalculateHeaderHash32() + cached.SealedChecksum;
return header;
}
public void Dispose() => _store.Dispose();
}

View file

@ -0,0 +1,71 @@
using System.Buffers;
using AcDream.Core.Net.Cryptography;
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), and the
/// unconditional counters. The inbound sequence tracker joins in N2 and the
/// <c>AckNakScheduler</c> in N3/N4 — N1 deliberately leaves ack behavior in
/// <c>WorldSession</c> untouched.
///
/// <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).
/// </para>
/// </summary>
internal sealed class ReliableTransport : IDisposable
{
public TransportClock Clock { get; }
public OutboundFlowQueue Outbound { get; }
public TransportStats Stats { get; }
public ReliableTransport(
IsaacRandom outboundIsaac,
ushort sessionClientId,
DatagramSendDelegate send,
TransportClock? clock = null,
ArrayPool<byte>? pool = null)
{
Clock = clock ?? new TransportClock();
Stats = new TransportStats();
Outbound = new OutboundFlowQueue(
outboundIsaac,
sessionClientId,
Clock,
Stats,
send,
pool);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
}
/// <summary>Last reliable sequence on the wire — the value unsequenced
/// control packets (the reflex 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).
/// </summary>
public void Sweep()
{
Clock.Update();
Outbound.TransmitPendingResends();
}
/// <summary>Returns every rented cache buffer to the pool.</summary>
public void Dispose() => Outbound.Dispose();
}

View file

@ -0,0 +1,131 @@
using System.Buffers;
namespace AcDream.Core.Net.Transport;
/// <summary>
/// FIFO cache of sent reliable datagrams awaiting the server's cumulative
/// ack, ported from retail's <c>SentPacketStore</c>:
/// <c>AddSentPacket @ 0x0054AB00</c> appends to the intrusive FIFO list
/// (<c>m_sentPacketList</c>, ctor/list plumbing @ 0x0054A8A0/0x0054A8D0);
/// <c>Flush @ 0x0054ACD0</c> pops from the head while the entry's sequence
/// is STRICTLY older than the watermark, wrap-safe (the inline
/// <c>lhs_newer</c> arithmetic breaks the walk at <c>seqNum_ == arg2</c> or
/// any not-older entry). The cache is unbounded like retail's — ACE acks
/// every 2 s, so steady state is tens of entries; depth is surfaced as the
/// watchdog, never silently capped (campaign doc §4).
///
/// <para>
/// Each entry keeps the COMPLETE wire buffer (20-byte header at
/// <c>[0..20)</c>, body at <c>[20..20+BodyLength)</c>) in an
/// ArrayPool-rented array, plus the two values a rebuilt resend header
/// needs: the sealed checksum (retail <c>NetPacket::checksum_</c> — the
/// pre-header-hash value) and the ISAAC key that sealed it (never redrawn —
/// campaign landmine #2).
/// </para>
///
/// <para>
/// Reliable packets never carry optional headers under the campaign's
/// standalone-control design (§4), which makes retail's
/// <c>NetPacket::RemoveDisposableOptionalHeaders @ 0x00549510</c> strip a
/// provable no-op — <see cref="Add"/> asserts it.
/// </para>
/// </summary>
internal sealed class SentPacketStore : IDisposable
{
/// <summary>One cached sent packet (campaign doc §4 cache entry).</summary>
internal readonly struct CachedPacket(
uint sequence,
byte[] buffer,
int bodyLength,
uint sealedChecksum,
uint isaacKey,
bool hasFragments)
{
/// <summary>Packet sequence (header verbatim on resend).</summary>
public uint Sequence { get; } = sequence;
/// <summary>Rented wire buffer: header [0..20), body [20..20+BodyLength).</summary>
public byte[] Buffer { get; } = buffer;
/// <summary>Body byte count (header DataSize verbatim on resend).</summary>
public int BodyLength { get; } = bodyLength;
/// <summary>Checksum value BEFORE the header hash is added:
/// <c>isaacKey ^ payloadHash</c> for encrypted packets (retail
/// <c>NetPacket::checksum_</c>).</summary>
public uint SealedChecksum { get; } = sealedChecksum;
/// <summary>The outbound ISAAC word that sealed this packet. Kept for
/// audit/diagnostics — a resend reuses <see cref="SealedChecksum"/>
/// and NEVER draws a new word (<c>CryptoSystem::EncryptData @
/// 0x0065FF40</c> non-null key path).</summary>
public uint IsaacKey { get; } = isaacKey;
/// <summary>True when the body carries fragments — the rebuilt
/// resend header ORs <c>BlobFragments</c> back in.</summary>
public bool HasFragments { get; } = hasFragments;
}
private readonly ArrayPool<byte> _pool;
private readonly Queue<CachedPacket> _fifo = new();
private readonly Dictionary<uint, CachedPacket> _bySequence = new();
public SentPacketStore(ArrayPool<byte>? pool = null) =>
_pool = pool ?? ArrayPool<byte>.Shared;
public int Count => _fifo.Count;
/// <summary>
/// Cache one successfully sent reliable packet
/// (<c>SentPacketStore::AddSentPacket @ 0x0054AB00</c>; the caller
/// commits AFTER the send succeeds, <c>FlowQueue::TransmitNewPackets @
/// 0x00547C85</c>). Takes ownership of <c>packet.Buffer</c>.
/// </summary>
/// <param name="optionalLength">
/// Asserted zero: our reliable packets never carry optional headers, so
/// retail's disposable-optional-header strip is a no-op (campaign §2.1).
/// </param>
public void Add(in CachedPacket packet, int optionalLength)
{
if (optionalLength != 0)
{
throw new InvalidOperationException(
"reliable packets must not carry optional headers — "
+ "NetPacket::RemoveDisposableOptionalHeaders @ 0x00549510 "
+ "is pinned as a no-op (campaign §2.1)");
}
_bySequence.Add(packet.Sequence, packet);
_fifo.Enqueue(packet);
}
public bool Contains(uint sequence) => _bySequence.ContainsKey(sequence);
public bool TryGet(uint sequence, out CachedPacket packet) =>
_bySequence.TryGetValue(sequence, out packet);
/// <summary>
/// Pop from the FIFO head while the head is STRICTLY older than
/// <paramref name="watermark"/>, wrap-safe — the watermark entry itself
/// survives (<c>SentPacketStore::Flush @ 0x0054ACD0</c>). Returned
/// buffers go back to the pool.
/// </summary>
public void FlushOlderThan(uint watermark)
{
while (_fifo.TryPeek(out CachedPacket head)
&& SequenceMath.IsNewer(watermark, head.Sequence))
{
_fifo.Dequeue();
_bySequence.Remove(head.Sequence);
_pool.Return(head.Buffer);
}
}
/// <summary>Return every rented buffer to the pool.</summary>
public void Dispose()
{
while (_fifo.TryDequeue(out CachedPacket entry))
_pool.Return(entry.Buffer);
_bySequence.Clear();
}
}

View file

@ -0,0 +1,30 @@
namespace AcDream.Core.Net.Transport;
/// <summary>
/// Wrap-safe 32-bit transport sequence comparisons, ported from retail
/// <c>TimeStampUtils::lhs_newer(uint32_t, uint32_t) @ 0x00543890</c>
/// (named-retail pseudo-C :334086): compute the unsigned distance, flip the
/// verdict when it exceeds <c>0x7FFFFFFF</c>. That is exactly the sign of the
/// two's-complement difference, so <see cref="IsNewer"/> reduces to one
/// signed comparison.
///
/// <para>
/// Boundary note: at a distance of exactly <c>0x80000000</c> retail's flip
/// arithmetic answers asymmetrically (the numerically smaller value reads as
/// newer); the signed-difference form answers "not newer" for both
/// directions. The half-window boundary is unreachable for transport
/// sequences (ACE terminates a session at a gap of 257 —
/// <c>AbnormalSequenceReceived</c>), so the campaign pins the simpler form
/// (campaign doc §4).
/// </para>
/// </summary>
internal static class SequenceMath
{
/// <summary>True when <paramref name="a"/> is strictly newer than
/// <paramref name="b"/> in wrap-safe sequence order.</summary>
public static bool IsNewer(uint a, uint b) => unchecked((int)(a - b)) > 0;
/// <summary>The wrap-safe newer of two sequence values (either one when
/// they are equal).</summary>
public static uint Max(uint a, uint b) => IsNewer(a, b) ? a : b;
}

View file

@ -0,0 +1,79 @@
using System.Diagnostics;
namespace AcDream.Core.Net.Transport;
/// <summary>
/// The transport's monotonic time authority: an injectable timestamp source
/// (production default <see cref="Stopwatch.GetTimestamp"/>) driving retail's
/// 0.5-second interval counter. <see cref="IntervalId"/> is the value retail
/// writes into <c>ProtoHeader::interval_</c> — our
/// <see cref="Packets.PacketHeader.Time"/> — on rebuilt resend headers.
///
/// <para>
/// Retail oracle: <c>ClientFlowQueue::IncrementLocalInterval @ 0x00547F10</c>
/// (named-retail pseudo-C :338389) — the tail is
/// <c>CurLocalInterval_.intervalID_ += elapsedIntervals</c>, advanced by the
/// caller once per elapsed 0.5-s slice. Only the interval counter is in
/// N1 scope: the same function's every-6-intervals TimeSync/EchoRequest
/// (~3 s) and every-0xDC-intervals CICMD keepalive are campaign §5
/// deferrals (TS-58) — standalone-unsafe against ACE's watermark hole.
/// </para>
///
/// <para>
/// One clock owns every transport gate (campaign §5 AP-126 — retail's
/// cur/local clock split is immaterial to the gates we port). Single-threaded
/// like the rest of the transport: <see cref="Update"/> runs only from the
/// session sweep.
/// </para>
/// </summary>
internal sealed class TransportClock
{
private readonly Func<long> _timestampSource;
private readonly long _ticksPerInterval;
private long _intervalBaseTimestamp;
/// <summary>Timestamp ticks per second of the injected source.</summary>
public long Frequency { get; }
/// <summary>
/// Retail's 0.5-s interval counter (<c>CurLocalInterval_.intervalID_</c>).
/// Starts at 1; wraps with natural ushort arithmetic.
/// </summary>
public ushort IntervalId { get; private set; }
public TransportClock(
Func<long>? timestampSource = null,
long? frequency = null)
{
_timestampSource = timestampSource ?? Stopwatch.GetTimestamp;
Frequency = frequency ?? Stopwatch.Frequency;
if (Frequency < 2)
{
throw new ArgumentOutOfRangeException(
nameof(frequency),
"the interval clock needs at least 2 ticks per second");
}
_ticksPerInterval = Frequency / 2;
_intervalBaseTimestamp = _timestampSource();
IntervalId = 1;
}
/// <summary>Current raw timestamp from the injected source.</summary>
public long GetTimestamp() => _timestampSource();
/// <summary>
/// Advance <see cref="IntervalId"/> by however many whole 0.5-s
/// intervals have elapsed since the last update. Called once per sweep.
/// </summary>
public void Update()
{
long elapsed = _timestampSource() - _intervalBaseTimestamp;
if (elapsed < _ticksPerInterval)
return;
long steps = elapsed / _ticksPerInterval;
IntervalId = unchecked((ushort)(IntervalId + steps));
_intervalBaseTimestamp += steps * _ticksPerInterval;
}
}

View file

@ -0,0 +1,38 @@
namespace AcDream.Core.Net.Transport;
/// <summary>
/// Unconditional reliable-transport counters. Increment sites are NOT
/// probe-gated — the counters are plain field writes and always current, so
/// a later probe (N5's <c>[net-tick]</c> extension) or a connected gate can
/// read them without having been armed in advance. Printing stays
/// probe-gated at the call sites that choose to surface them.
/// </summary>
internal sealed class TransportStats
{
/// <summary>Datagrams re-emitted in response to a server NAK.</summary>
public long ResendsSent;
/// <summary>Inbound packets carrying <c>RequestRetransmit</c>.</summary>
public long NakRequestsReceived;
/// <summary>
/// NAKed ids no longer (or never) in the sent-packet cache, dropped
/// silently instead of answering retail's <c>RejectRetransmit</c>
/// (divergence register TS-57 — ACE no-ops the reject, and the
/// standalone unsequenced form would trip ACE's watermark hole).
/// </summary>
public long UncachedNakIds;
/// <summary>Inbound <c>AckSequence</c> values folded into the watermark
/// (explicit acks plus the NAK <c>ids[0]</c> implicit ack).</summary>
public long AcksConsumed;
/// <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>
public int CacheDepth => CacheDepthSource?.Invoke() ?? 0;
/// <summary>Wired by <see cref="ReliableTransport"/> to the store's
/// <c>Count</c>.</summary>
internal Func<int>? CacheDepthSource { get; set; }
}

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);
}