feat(net): N2 - inbound sequence-aligned ISAAC + NAK set

Campaign N Slice N2 (docs/plans/2026-07-29-network-transport-campaign.md
S2.2) - the second fatal #260 fix: the inbound keystream now aligns to
SEQUENCE order instead of arrival order. One lost S2C datagram no longer
desyncs the inbound cipher permanently - the missing id's pre-drawn key
parks in the NAK set, later packets keep decoding, and the retransmission
decodes with the parked key.

New src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs - retail's
ReceiverData inbound half, ported rule for rule:
- Sanity window: drop when seq is wrap-safe newer than
  highestIDReceived_ + 0x7FFF (SharedNet::SeqIDSanityCheck @ 0x00543A20;
  the boundary itself is accepted).
- Duplicate/late arrival (encrypted, at/below the watermark): NAK-set
  hit -> decrypt with the PARKED pre-drawn key; miss -> silent drop at
  ZERO keystream cost (SharedNet::ProcessNewSeqNum @ 0x00544690, the
  AVL::Remove branch) - the dup-word-burn and double-dispatch bugs close
  together.
- Gap walk (SharedNet::ProcessNewestSeqNum @ 0x00541930): one inbound
  ISAAC word per missing id, drawn IN SEQUENCE ORDER BEFORE the arriving
  packet's own key (landmine #4), parked beside the id
  (ReceiverData::AddNakked @ 0x00549240, idempotent; id 0 skipped per
  retail's `if (esi_1 != 0)`). Cleartext walks to seq+1 - the borrowed
  id itself gets NAKed, so the real encrypted packet at that id can
  still decode later.
- Verify-failure re-park: a sequenced encrypted checksum failure parks
  the consumed key back beside its id so the retransmission decodes
  (SharedNet::ProcessPacket @ 0x00544790 tail, AddNakked(seq, &key)).
- Inbound RejectRetransmit -> silent NAK-set abandonment; parked keys
  discarded, alignment holds because the words were already drawn
  (SharedNet::HandleEmptyAck @ 0x005448F0).
- NAK set = SortedDictionary<uint,uint> seq -> parked key; ascending
  raw-uint enumeration matches retail's AVL walk for N4's <=114-id NAK
  emission (ReceiverData::GetNaks @ 0x005490C0).

PacketCodec split (campaign S4, retail's own factoring - the key is an
optional in/out of ReceiverData::Decrypt): TryParseBorrowed is the pure
parse + checksum-summand computation with NO keystream access anywhere;
VerifyChecksum(header, headerHash, payloadHash, uint? key) compares the
additive cleartext form (null) or headerHash + (key ^ payloadHash).
TryDecodeBorrowed(datagram, IsaacRandom?) - the consume-before-compare
site that WAS the bug - is deleted; the owned TryDecode stays
(test-only). RejectRetransmit ids are now exposed on both decoders
(borrowed RejectRetransmitBytes/Count like the Request pair; owned
RejectRetransmits list); the bytes were always inside the hashed span,
so parse-hash coverage is unchanged.

WorldSession: ProcessDatagram head is now parse -> sequence-0 split
(cleartext seq-0 = handshake/control, verified additively and processed
as before; encrypted seq-0 dropped before any keystream access, like
retail's ProcessPacket) -> tracker.Admit -> VerifyChecksum with the
admission key -> failure re-park -> unchanged flag handling, N1
transport consumption, reflex ack, and fragment loop. The
RejectRetransmit flag routes to the tracker beside the N1 NAK/ack
consumption. The handshake Connect loop moved to parse +
cleartext-verify (no tracker exists before ISAAC seeding; the
ConnectRequest is cleartext seq 0). ReliableTransport now takes both
Isaacs and exposes Inbound; the session's _inboundIsaac field is
deleted. No production caller constructed the N1 ctor outside
WorldSession, so no compatibility shape was kept.

TransportStats gains InboundDupsDropped, InboundSanityDrops,
ChecksumFailures, KeysParked (unconditional, like the N1 counters).

Watermark init = 1 is an ACE adaptation, register row AD-50 (watermark
INIT only, not a mechanism change; AD-49 stays reserved for the campaign
S5 blob-layer deferral): retail zero-inits ReceiverData, but ACE never
emits S2C sequence 1 - PacketSequence starts unprimed at uint.MaxValue,
the cleartext ConnectRequest takes NextValue 0, and the first ENCRYPTED
flush re-primes CurrentValue to 1 so the first encrypted sequenced
packet is 2 (ACE NetworkSession.cs:716-717 resolving to
UIntSequence(startingValue: 1), Sequence/UIntSequence.cs:9-13,30-41).
A zero-init watermark would gap-walk the permanent id-1 hole: one
spurious NAK, the first pre-drawn word mis-assigned to id 1, and the
keystream off by one from the first encrypted packet onward. holtburger
seeds the same value (crates/holtburger-session/src/session/api.rs:30,
last_server_seq: 1), mirroring ACE's own C2S-side
lastReceivedPacketSequence = 1 (NetworkSession.cs:57). The N0 model's
dance is pinned by the clean-lifecycle conformance test: min encrypted
S2C sequence == 2, zero NAKs, zero spurious drops.

Tests (+14; Core.Net 702 -> 716): the decisive gap test (10,11,13,14 -
13 and 14 decode with fresh words while 12's key parks with
KeysParked=1/NakCount=1, the late 12 decodes with the parked key, 15
takes the next fresh word - impossible pre-N2), zero-cost duplicate
drop (shadow ISAAC position unchanged), re-park -> byte-identical
retransmission decode, the cleartext borrowed-id rule, cleartext at the
watermark (no NAK/key/watermark change), sanity boundary +0x7FFF
accepted / +0x8000 dropped wrap-safe, skip-id-0 across the 32-bit wrap
with ascending NAK enumeration, RejectRetransmit abandonment with
alignment held, warm zero-alloc Admit; plus four real-WorldSession
conformance runs against the N0 ACE double: clean lifecycle (zero NAKs
at every stage), S2C loss of one packet of a Count=2 fragment set
(later packets STILL decode - the N2 win; late byte-identical
redelivery completes the split message intact), duplicate delivery
dropped BEFORE dispatch, and the seq-0 tracker bypass.

N3/N4 handoff notes are recorded in the campaign S9 N2 row: the interim
per-packet reflex ack acks the arriving sequence even while a gap is
parked (ACE prunes the lost id from its S2C cache before N4 could NAK
it - message recovery needs N3's retail NAK-xor-ack sweep), and ACE's
RejectRetransmit consumes a fresh CLEARTEXT sequence with no keystream
word, an ACE-vs-retail wrinkle N4's design must resolve.

Gates: dotnet build green; AcDream.Core.Net.Tests 716/716;
full-solution Release 9,732 passed / 5 skipped / 0 failed; connected
world-lifecycle gate vs local ACE RESULT=PASS (zero failures, one
pre-existing expected world-edge landblock-miss warning); canonical
nine-stop connected route RESULT=PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 13:10:20 +02:00
parent 66513b16db
commit 46d209d053
12 changed files with 1359 additions and 180 deletions

View file

@ -28,14 +28,9 @@ internal readonly record struct BorrowedOptionalHeader(
uint ConnectRequestClientSeed,
ReadOnlyMemory<byte> RawBytes,
ReadOnlyMemory<byte> RetransmitRequestBytes,
int RetransmitRequestCount);
internal readonly record struct BorrowedPacketDecodeResult(
BorrowedPacket Packet,
PacketCodec.DecodeError Error)
{
public bool IsOk => Error == PacketCodec.DecodeError.None;
}
int RetransmitRequestCount,
ReadOnlyMemory<byte> RejectRetransmitBytes,
int RejectRetransmitCount);
internal readonly record struct BorrowedMessageFragment(
MessageFragmentHeader Header,

View file

@ -116,29 +116,41 @@ public static class PacketCodec
}
/// <summary>
/// Decode and verify one packet without materializing packet, optional-
/// header, fragment-list, body, or fragment-payload objects. Returned
/// memory borrows <paramref name="datagram"/> and must not outlive it.
/// Parse one packet without materializing packet, optional-header,
/// fragment-list, body, or fragment-payload objects, and WITHOUT
/// touching any keystream: checksum verification is the separate
/// <see cref="VerifyChecksum"/> step, because the key decision belongs
/// to the inbound sequence tracker (campaign doc §2.2 — retail's own
/// factoring: <c>ReceiverData::Decrypt</c> takes the key as an optional
/// in/out). Returned memory borrows <paramref name="datagram"/> and
/// must not outlive it. On success <paramref name="headerHash"/> and
/// <paramref name="payloadHash"/> carry the two checksum summands
/// (payload = optional-section hash + Σ fragment hashes).
/// </summary>
internal static BorrowedPacketDecodeResult TryDecodeBorrowed(
internal static bool TryParseBorrowed(
ReadOnlyMemory<byte> datagram,
IsaacRandom? inboundIsaac)
out BorrowedPacket packet,
out uint headerHash,
out uint payloadHash,
out DecodeError error)
{
packet = default;
headerHash = 0;
payloadHash = 0;
ReadOnlySpan<byte> wire = datagram.Span;
if (wire.Length < PacketHeader.Size)
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.TooShort);
error = DecodeError.TooShort;
return false;
}
PacketHeader header = PacketHeader.Unpack(wire);
int bodyLength = header.DataSize;
if (wire.Length - PacketHeader.Size < bodyLength)
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.HeaderSizeExceedsBuffer);
error = DecodeError.HeaderSizeExceedsBuffer;
return false;
}
ReadOnlyMemory<byte> body = datagram.Slice(
@ -150,9 +162,8 @@ public static class PacketCodec
out BorrowedOptionalHeader optional,
out int optionalConsumed))
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.InvalidOptionalHeader);
error = DecodeError.InvalidOptionalHeader;
return false;
}
ReadOnlyMemory<byte> fragmentBytes =
@ -171,9 +182,8 @@ public static class PacketCodec
out int payloadLength,
out int consumed))
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.InvalidFragment);
error = DecodeError.InvalidFragment;
return false;
}
fragmentHash +=
@ -190,47 +200,39 @@ public static class PacketCodec
}
}
uint headerHash = header.CalculateHeaderHash32();
uint optionalHash = Hash32.Calculate(
optional.RawBytes.Span);
uint payloadHash = optionalHash + fragmentHash;
if (header.HasFlag(
PacketHeaderFlags.EncryptedChecksum))
{
if (inboundIsaac is null)
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.ChecksumMismatch);
}
uint expectedKey =
(header.Checksum - headerHash) ^ payloadHash;
uint isaacKey = inboundIsaac.Next();
if (expectedKey != isaacKey)
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.ChecksumMismatch);
}
}
else if (header.Checksum != headerHash + payloadHash)
{
return new BorrowedPacketDecodeResult(
default,
DecodeError.ChecksumMismatch);
}
return new BorrowedPacketDecodeResult(
new BorrowedPacket(
header,
optional,
body,
fragmentBytes,
fragmentCount),
DecodeError.None);
headerHash = header.CalculateHeaderHash32();
payloadHash =
Hash32.Calculate(optional.RawBytes.Span) + fragmentHash;
packet = new BorrowedPacket(
header,
optional,
body,
fragmentBytes,
fragmentCount);
error = DecodeError.None;
return true;
}
/// <summary>
/// Verify a parsed packet's checksum from the
/// <see cref="TryParseBorrowed"/> summands. <paramref name="isaacKey"/>
/// null → the additive cleartext form
/// (<c>checksum == headerHash + payloadHash</c>); non-null → the
/// encrypted form
/// (<c>checksum == headerHash + (key ^ payloadHash)</c>). The caller
/// (the inbound sequence tracker via <c>WorldSession</c>) owns every
/// key decision — parked vs freshly drawn — so this function never
/// touches a keystream.
/// </summary>
internal static bool VerifyChecksum(
in PacketHeader header,
uint headerHash,
uint payloadHash,
uint? isaacKey) =>
isaacKey is uint key
? header.Checksum == headerHash + (key ^ payloadHash)
: header.Checksum == headerHash + payloadHash;
private static bool TryParseBorrowedOptional(
ReadOnlyMemory<byte> bodyMemory,
PacketHeaderFlags flags,
@ -251,6 +253,8 @@ public static class PacketCodec
uint connectRequestClientSeed = 0;
int retransmitOffset = 0;
int retransmitCount = 0;
int rejectOffset = 0;
int rejectCount = 0;
if (HasFlag(flags, PacketHeaderFlags.ServerSwitch)
&& !Take(body, ref position, 8))
@ -288,7 +292,13 @@ public static class PacketCodec
{
return Invalid(out optional, out consumed);
}
position += checked((int)count * 4);
// N2: expose the abandoned ids (SharedNet::HandleEmptyAck
// @ 0x005448F0 consumes them). The bytes were always inside the
// hashed span; only the borrowed view is new.
rejectOffset = position;
rejectCount = checked((int)count);
position += rejectCount * 4;
}
if (HasFlag(flags, PacketHeaderFlags.AckSequence))
@ -318,7 +328,9 @@ public static class PacketCodec
connectRequestServerSeed,
connectRequestClientSeed,
retransmitOffset,
retransmitCount);
retransmitCount,
rejectOffset,
rejectCount);
consumed = position;
return true;
}
@ -418,7 +430,9 @@ public static class PacketCodec
connectRequestServerSeed,
connectRequestClientSeed,
retransmitOffset,
retransmitCount);
retransmitCount,
rejectOffset,
rejectCount);
consumed = position;
return true;
}
@ -437,7 +451,9 @@ public static class PacketCodec
uint connectRequestServerSeed,
uint connectRequestClientSeed,
int retransmitOffset,
int retransmitCount) =>
int retransmitCount,
int rejectOffset,
int rejectCount) =>
new(
ackSequence,
timeSync,
@ -455,7 +471,13 @@ public static class PacketCodec
: body.Slice(
retransmitOffset,
retransmitCount * 4),
retransmitCount);
retransmitCount,
rejectCount == 0
? ReadOnlyMemory<byte>.Empty
: body.Slice(
rejectOffset,
rejectCount * 4),
rejectCount);
private static bool Invalid(
out BorrowedOptionalHeader optional,

View file

@ -38,6 +38,10 @@ public sealed class PacketHeaderOptional
public uint AckSequence { get; private set; }
public IReadOnlyList<uint> RetransmitRequests { get; private set; } = Array.Empty<uint>();
/// <summary>N2: sequence ids the server refuses to retransmit
/// (<c>RejectRetransmit</c> 0x2000) — the client abandons the matching
/// NAK-set entries (<c>SharedNet::HandleEmptyAck @ 0x005448F0</c>).</summary>
public IReadOnlyList<uint> RejectRetransmits { get; private set; } = Array.Empty<uint>();
public double TimeSync { get; private set; }
public float EchoRequestClientTime { get; private set; }
public uint FlowBytes { get; private set; }
@ -94,7 +98,13 @@ public sealed class PacketHeaderOptional
if (!Take(body, ref pos, 4)) return -1;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
if (count > 1024 || body.Length - pos < (int)count * 4) return -1;
pos += (int)count * 4; // consume without storing
var rejected = new uint[count];
for (int i = 0; i < count; i++)
{
rejected[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
RejectRetransmits = rejected;
}
if (HasFlag(flags, PacketHeaderFlags.AckSequence))

View file

@ -0,0 +1,257 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Cryptography;
namespace AcDream.Core.Net.Transport;
/// <summary>
/// The inbound half of retail's reliable transport (<c>ReceiverData</c>
/// under <c>SharedNet::receivers_</c>): owns the inbound ISAAC keystream,
/// the received-sequence watermark (<c>highestIDReceived_</c>), and the NAK
/// set (<c>m_SeqIDsWeNAKed</c> — retail keeps an AVL of sequence → parked
/// keystream word; a <see cref="SortedDictionary{TKey,TValue}"/> gives the
/// same raw-uint in-order enumeration N4's NAK emission needs).
///
/// <para>
/// The invariant the whole inbound port hangs on (campaign doc §2.2): the
/// inbound keystream is aligned to SEQUENCE order, not arrival order. There
/// is no transport reorder buffer — packets process on arrival
/// (<c>SharedNet::ProcessPacket @ 0x00544790</c> →
/// <c>ProcessNewSeqNum @ 0x00544690</c>); when a gap opens, one keystream
/// word per missing id is pre-drawn IN SEQUENCE ORDER and parked beside the
/// id, BEFORE the arriving packet's own key (landmine #4 — reversed, the
/// stream is off by the gap size forever).
/// </para>
///
/// <para>Ported rules, per packet:</para>
/// <list type="number">
/// <item>Sanity (<c>SharedNet::SeqIDSanityCheck @ 0x00543A20</c>): drop when
/// the sequence is wrap-safe newer than <c>highestIDReceived_ + 0x7FFF</c>.</item>
/// <item>Encrypted AND not newer than the watermark = duplicate/late
/// arrival: remove the sequence from the NAK set — hit → decrypt with the
/// PARKED pre-drawn key; miss → drop silently at ZERO keystream cost
/// (<c>ProcessNewSeqNum @ 0x00544690</c>, the <c>AVL::Remove</c> branch).</item>
/// <item>Newer than the watermark → gap walk
/// (<c>SharedNet::ProcessNewestSeqNum @ 0x00541930</c>): walk end =
/// encrypted ? seq : seq + 1 (a cleartext packet borrows an
/// already-delivered sequence, so the borrowed id itself gets NAKed — the
/// real encrypted packet at that id may still be in flight); for each id
/// (skipping id 0) <c>AddNakked(id, null)</c> pre-draws one word; then the
/// watermark becomes seq.</item>
/// <item>An encrypted packet's own verify key: the parked key when step 2
/// found one, else the NEXT drawn word (<c>ReceiverData::Decrypt</c>'s
/// optional in/out key — the same factoring
/// <c>CryptoSystem::EncryptData @ 0x0065FF40</c> uses outbound).</item>
/// <item>Checksum-verify FAILURE on a sequenced encrypted packet →
/// <see cref="ReparkKey"/> re-parks the consumed key
/// (<c>ProcessPacket @ 0x00544790</c> tail: <c>AddNakked(seq, &amp;key)</c>)
/// so the retransmission decodes.</item>
/// <item>Inbound RejectRetransmit
/// (<c>SharedNet::HandleEmptyAck @ 0x005448F0</c>) →
/// <see cref="OnRejectRetransmit"/> removes the ids — silent abandonment;
/// the parked keys are discarded and alignment holds because the words were
/// already drawn.</item>
/// </list>
///
/// <para>
/// Single-threaded by design (the ISAAC keystream is order-sensitive), like
/// the rest of the transport: every member runs on the session's frame
/// thread.
/// </para>
/// </summary>
internal sealed class InboundSequenceTracker
{
/// <summary>
/// ACE ADAPTATION (watermark INIT only, not a mechanism change; register
/// AD-50): retail zero-initializes <c>ReceiverData</c> (so
/// <c>highestIDReceived_</c> starts 0), but ACE never emits S2C sequence
/// 1 — its <c>PacketSequence</c> starts unprimed at
/// <c>uint.MaxValue</c>, the cleartext ConnectRequest takes NextValue 0,
/// and the first ENCRYPTED flush re-primes CurrentValue to 1 so the
/// first encrypted sequenced packet is 2 (ACE
/// NetworkSession.cs:716-717 + Sequence/UIntSequence.cs:9-13,30-41,
/// verified against the N0 double). A zero-init watermark would walk the
/// permanent id-1 hole into a spurious NAK, mis-assign the pre-drawn
/// word to id 1, and desync the keystream on the very first encrypted
/// packet. holtburger seeds the same value for the same reason
/// (crates/holtburger-session/src/session/api.rs:30,
/// <c>last_server_seq: 1</c>), mirroring ACE's own C2S-side
/// <c>lastReceivedPacketSequence = 1</c> (NetworkSession.cs:57).
/// </summary>
internal const uint AceInitialWatermark = 1;
/// <summary>Retail's acceptance horizon above the watermark
/// (<c>SeqIDSanityCheck @ 0x00543A20</c>).</summary>
internal const uint SanityWindow = 0x7FFF;
private readonly IsaacRandom _inboundIsaac;
private readonly TransportStats _stats;
/// <summary>Retail <c>m_SeqIDsWeNAKed</c>: missing sequence → the
/// pre-drawn keystream word parked for it.</summary>
private readonly SortedDictionary<uint, uint> _nakSet = new();
/// <summary>Retail <c>highestIDReceived_</c> — the newest sequence ever
/// admitted (NOT "highest fully processed"; the gap walk advances it
/// past holes).</summary>
public uint HighestIdReceived { get; private set; }
/// <summary>Missing ids currently carrying a parked key — the value
/// N4's <c>RequestRetransmit</c> emission drains.</summary>
public int NakCount => _nakSet.Count;
public InboundSequenceTracker(
IsaacRandom inboundIsaac,
TransportStats stats,
uint initialWatermark = AceInitialWatermark)
{
ArgumentNullException.ThrowIfNull(inboundIsaac);
ArgumentNullException.ThrowIfNull(stats);
_inboundIsaac = inboundIsaac;
_stats = stats;
HighestIdReceived = initialWatermark;
}
/// <summary>
/// The verdict for one arriving sequenced packet. <see cref="Drop"/> —
/// discard without touching the checksum. Otherwise verify with
/// <see cref="VerifyKey"/>: the keystream word for encrypted packets,
/// null for the additive cleartext form.
/// </summary>
public readonly record struct Admission(bool Drop, uint? VerifyKey)
{
public static Admission Dropped => new(true, null);
public static Admission Process(uint? verifyKey) =>
new(false, verifyKey);
}
/// <summary>
/// Steps 14 above for one arriving packet with a non-zero sequence.
/// Sequence-0 packets never reach the tracker: retail's
/// <c>ProcessPacket @ 0x00544790</c> routes cleartext seq-0
/// (handshake/control) around <c>ProcessNewSeqNum</c> entirely and drops
/// encrypted seq-0 — the caller owns that split.
/// </summary>
public Admission Admit(uint sequence, bool encrypted)
{
// Step 1 — SeqIDSanityCheck @ 0x00543A20: wrap-safe horizon.
// highest + 0x7FFF itself is accepted; one past it is dropped.
if (SequenceMath.IsNewer(
sequence,
unchecked(HighestIdReceived + SanityWindow)))
{
_stats.InboundSanityDrops++;
return Admission.Dropped;
}
bool newer = SequenceMath.IsNewer(sequence, HighestIdReceived);
// Step 2 — duplicate/late arrival (encrypted, at or below the
// watermark): a NAK-set hit hands back the parked pre-drawn key; a
// miss is a duplicate of an already-decoded packet and drops at
// zero keystream cost. Cleartext packets skip this entirely
// (retail reprocesses cleartext dups; they never touch the wheel).
uint? parkedKey = null;
if (encrypted && !newer)
{
if (!_nakSet.Remove(sequence, out uint parked))
{
_stats.InboundDupsDropped++;
return Admission.Dropped;
}
parkedKey = parked;
}
// Step 3 — gap walk (ProcessNewestSeqNum @ 0x00541930): pre-draw
// one word per missing id IN SEQUENCE ORDER, BEFORE the arriving
// packet's own key (landmine #4). Cleartext walks one past its own
// sequence — the borrowed-id rule — and id 0 is skipped (retail's
// `if (esi_1 != 0)`).
if (newer)
{
uint end = encrypted ? sequence : unchecked(sequence + 1u);
for (uint id = unchecked(HighestIdReceived + 1u);
id != end;
id = unchecked(id + 1u))
{
if (id != 0)
AddNakked(id);
}
HighestIdReceived = sequence;
}
if (!encrypted)
return Admission.Process(null);
// Step 4 — the packet's own key: parked when step 2 found one,
// else the next fresh word.
return Admission.Process(parkedKey ?? _inboundIsaac.Next());
}
/// <summary>
/// Step 5 — checksum-verify failure on a sequenced encrypted packet:
/// park the consumed key back beside its sequence
/// (<c>ProcessPacket @ 0x00544790</c> tail,
/// <c>AddNakked(seq, &amp;key)</c>) so the byte-identical retransmission
/// decodes with the same word. Idempotent like retail's AddNakked.
/// </summary>
public void ReparkKey(uint sequence, uint key)
{
if (_nakSet.ContainsKey(sequence))
return;
_nakSet.Add(sequence, key);
_stats.KeysParked++;
}
/// <summary>
/// Step 6 — inbound RejectRetransmit
/// (<c>SharedNet::HandleEmptyAck @ 0x005448F0</c>): the server no longer
/// has these ids; abandon them silently. The parked keys are discarded —
/// alignment holds because the words were already drawn in sequence
/// order. <paramref name="idBytes"/> is the borrowed optional header's
/// raw little-endian u32 id list.
/// </summary>
public void OnRejectRetransmit(ReadOnlySpan<byte> idBytes, int count)
{
if (count <= 0 || idBytes.Length < count * 4)
return;
for (int i = 0; i < count; i++)
{
_nakSet.Remove(
BinaryPrimitives.ReadUInt32LittleEndian(
idBytes.Slice(i * 4)));
}
}
/// <summary>
/// Copy the NAKed ids in ascending raw-uint order — the same in-order
/// enumeration retail's AVL yields (<c>ReceiverData::GetNaks
/// @ 0x005490C0</c> walks it ascending for the ≤114-id NAK list). N4
/// adds the cap; this is the simple full copy.
/// </summary>
public void CopyNakkedSequencesAscending(List<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach (uint sequence in _nakSet.Keys)
destination.Add(sequence);
}
/// <summary>
/// <c>ReceiverData::AddNakked @ 0x00549240</c> with a null key pointer:
/// idempotent; a missing entry pre-draws ONE inbound keystream word
/// (<c>CryptoSystem::GetNextCryptoSeed</c>) and parks it beside the id.
/// </summary>
private void AddNakked(uint sequence)
{
if (_nakSet.ContainsKey(sequence))
return;
_nakSet.Add(sequence, _inboundIsaac.Next());
_stats.KeysParked++;
}
}

View file

@ -5,9 +5,9 @@ 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
/// 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.
///
/// <para>
@ -27,10 +27,16 @@ internal sealed class ReliableTransport : IDisposable
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; }
public TransportStats Stats { get; }
public ReliableTransport(
IsaacRandom outboundIsaac,
IsaacRandom inboundIsaac,
ushort sessionClientId,
DatagramSendDelegate send,
TransportClock? clock = null,
@ -45,6 +51,7 @@ internal sealed class ReliableTransport : IDisposable
Stats,
send,
pool);
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
}

View file

@ -27,6 +27,26 @@ internal sealed class TransportStats
/// (explicit acks plus the NAK <c>ids[0]</c> implicit ack).</summary>
public long AcksConsumed;
/// <summary>N2: inbound duplicates of already-decoded packets, dropped
/// at zero keystream cost (encrypted, at/below the watermark, no parked
/// key — <c>ProcessNewSeqNum @ 0x00544690</c>).</summary>
public long InboundDupsDropped;
/// <summary>N2: inbound packets past the wrap-safe
/// <c>watermark + 0x7FFF</c> horizon
/// (<c>SeqIDSanityCheck @ 0x00543A20</c>).</summary>
public long InboundSanityDrops;
/// <summary>N2: inbound packets whose checksum failed verification
/// after admission (a sequenced encrypted failure also re-parks its
/// consumed key for the retransmission).</summary>
public long ChecksumFailures;
/// <summary>N2: inbound keystream words parked in the NAK set — one per
/// gap-walked missing id (<c>ReceiverData::AddNakked @ 0x00549240</c>
/// pre-draw) plus one per checksum-failure re-park.</summary>
public long KeysParked;
/// <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

@ -673,17 +673,16 @@ public sealed class WorldSession : IDisposable
Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1";
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
private IsaacRandom? _inboundIsaac;
private ushort _sessionClientId;
private ushort _sessionIteration;
private bool _transportNegotiated;
/// <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).
/// Campaign N Slices N1+N2: the reliable transport — both ISAAC
/// keystreams, packet/fragment sequences, sent-packet cache, resend on
/// NAK (outbound), and the sequence-aligned inbound tracker + NAK set
/// (inbound). Constructed at ISAAC-seeding time in <see cref="Connect"/>;
/// null before negotiation (neither keystream exists yet).
/// </summary>
private ReliableTransport? _transport;
@ -827,22 +826,39 @@ public sealed class WorldSession : IDisposable
PooledInboundDatagram datagram = received.Value;
try
{
BorrowedPacketDecodeResult dec =
PacketCodec.TryDecodeBorrowed(
// N2: pure parse + cleartext verify. No tracker exists
// before the ISAAC seeds do, and the ConnectRequest is a
// cleartext sequence-0 handshake packet; anything encrypted
// here is undecodable and skipped, matching the pre-N2
// null-keystream behavior.
bool parsedOk = PacketCodec.TryParseBorrowed(
datagram.Memory,
inboundIsaac: null);
if (dec.IsOk
&& dec.Packet.Header.HasFlag(
out BorrowedPacket parsed,
out uint headerHash,
out uint payloadHash,
out _);
PacketHeader parsedHeader = parsed.Header;
if (parsedOk
&& !parsedHeader.HasFlag(
PacketHeaderFlags.EncryptedChecksum)
&& PacketCodec.VerifyChecksum(
in parsedHeader,
headerHash,
payloadHash,
isaacKey: null)
&& parsedHeader.HasFlag(
PacketHeaderFlags.ConnectRequest))
{
connectRequest = dec.Packet.Optional with
connectRequest = parsed.Optional with
{
RawBytes = ReadOnlyMemory<byte>.Empty,
RetransmitRequestBytes =
ReadOnlyMemory<byte>.Empty,
RejectRetransmitBytes =
ReadOnlyMemory<byte>.Empty,
};
connectRequestIteration =
dec.Packet.Header.Iteration;
parsedHeader.Iteration;
connectRequestReceived = true;
}
}
@ -869,20 +885,22 @@ public sealed class WorldSession : IDisposable
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
_inboundIsaac = new IsaacRandom(serverSeedBytes);
_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.
// 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).
_transport = new ReliableTransport(
new IsaacRandom(clientSeedBytes),
new IsaacRandom(serverSeedBytes),
_sessionClientId,
datagram => _net.Send(datagram));
_transportNegotiated = true;
@ -1292,19 +1310,87 @@ public sealed class WorldSession : IDisposable
List<uint>? opcodesOut = null,
bool dispatchWorldEvents = true)
{
BorrowedPacketDecodeResult dec =
PacketCodec.TryDecodeBorrowed(
if (!PacketCodec.TryParseBorrowed(
bytes,
_inboundIsaac);
if (!dec.IsOk) return;
out BorrowedPacket packet,
out uint headerHash,
out uint payloadHash,
out _))
{
return;
}
PacketHeader serverHeader = packet.Header;
bool encrypted = serverHeader.HasFlag(
PacketHeaderFlags.EncryptedChecksum);
// N2: retail's inbound admission split (SharedNet::ProcessPacket
// @ 0x00544790 → ProcessNewSeqNum @ 0x00544690). Sequence-0 packets
// bypass the tracker entirely: cleartext seq-0 is handshake/control
// (verified additively, processed as before); encrypted seq-0 does
// not exist on the wire and drops before any keystream is touched.
if (serverHeader.Sequence == 0)
{
if (encrypted
|| !PacketCodec.VerifyChecksum(
in serverHeader,
headerHash,
payloadHash,
isaacKey: null))
{
return;
}
}
else if (_transport is { } inboundTransport)
{
InboundSequenceTracker.Admission admission =
inboundTransport.Inbound.Admit(
serverHeader.Sequence,
encrypted);
if (admission.Drop)
return;
if (!PacketCodec.VerifyChecksum(
in serverHeader,
headerHash,
payloadHash,
admission.VerifyKey))
{
inboundTransport.Stats.ChecksumFailures++;
// Verify failure on a sequenced encrypted packet re-parks
// the consumed key so the retransmission decodes
// (ProcessPacket @ 0x00544790 tail — campaign §2.2 step 5).
if (encrypted)
{
inboundTransport.Inbound.ReparkKey(
serverHeader.Sequence,
admission.VerifyKey!.Value);
}
return;
}
}
else
{
// Sequenced traffic before negotiation — the pre-N2 behavior of
// a null inbound keystream: encrypted cannot verify; cleartext
// verifies additively.
if (encrypted
|| !PacketCodec.VerifyChecksum(
in serverHeader,
headerHash,
payloadHash,
isaacKey: null))
{
return;
}
}
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
// last-heard clock only for valid server traffic. Record at decode
// last-heard clock only for valid server traffic. Record at checksum
// 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).
@ -1315,17 +1401,29 @@ public sealed class WorldSession : IDisposable
// 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)
&& packet.Optional.RetransmitRequestCount > 0)
{
transport.Outbound.OnRetransmitRequest(
dec.Packet.Optional.RetransmitRequestBytes.Span,
dec.Packet.Optional.RetransmitRequestCount);
packet.Optional.RetransmitRequestBytes.Span,
packet.Optional.RetransmitRequestCount);
}
// N2: inbound RejectRetransmit (0x2000) — the server abandoned
// these ids; drop them from the NAK set, discarding the parked
// keys (SharedNet::HandleEmptyAck @ 0x005448F0). Alignment
// holds: the words were already drawn in sequence order.
if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0
&& packet.Optional.RejectRetransmitCount > 0)
{
transport.Inbound.OnRejectRetransmit(
packet.Optional.RejectRetransmitBytes.Span,
packet.Optional.RejectRetransmitCount);
}
// 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);
transport.Outbound.OnAckSequence(packet.Optional.AckSequence);
}
// Phase 4.9: send an ACK_SEQUENCE control packet for every received
@ -1346,7 +1444,7 @@ public sealed class WorldSession : IDisposable
// periodically — no explicit opcode, just the header flag.
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
{
double t = dec.Packet.Optional.TimeSync;
double t = packet.Optional.TimeSync;
if (t > 0)
{
LastServerTimeTicks = t;
@ -1355,7 +1453,7 @@ public sealed class WorldSession : IDisposable
}
foreach (BorrowedMessageFragment frag
in dec.Packet.Fragments)
in packet.Fragments)
{
if (!_assembler.TryIngest(
frag,