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

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests;
@ -64,16 +65,26 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
Assert.Equal(1, processed);
}
/// <summary>
/// The loop + channel preserve arrival order end-to-end. Pre-N3 this
/// was asserted through the per-packet reflex acks; the AckNakScheduler
/// replaced those with one cumulative ack per 2.0 s (retail
/// <c>SharedNet::EnqueuePak @ 0x00543B10</c>), so the ordering witness
/// is now the dispatched message stream itself — and the session must
/// send NO per-packet acks at all.
/// </summary>
[Fact]
public async Task NetReceiveLoopAsync_PreservesArrivalAndAckOrder()
public async Task NetReceiveLoopAsync_PreservesArrivalOrder_NoReflexAcks()
{
var transport = new OrderedDatagramTransport(
BuildPacket(sequence: 41),
BuildPacket(sequence: 42),
BuildPacket(sequence: 43));
BuildPacket(sequence: 41, fragmentSequence: 1, "first"),
BuildPacket(sequence: 42, fragmentSequence: 2, "second"),
BuildPacket(sequence: 43, fragmentSequence: 3, "third"));
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
var messages = new List<string>();
session.ServerMessageReceived += m => messages.Add(m.Message);
MethodInfo loopMethod = typeof(WorldSession).GetMethod(
"NetReceiveLoopAsync",
BindingFlags.NonPublic | BindingFlags.Instance)!;
@ -82,29 +93,44 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
await task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(3, session.Tick());
uint[] acked = transport.Sent
.Select(static bytes =>
PacketCodec.TryDecode(
bytes,
inboundIsaac: null))
.Select(static decoded =>
{
Assert.True(decoded.IsOk, decoded.Error.ToString());
return decoded.Packet!.Optional.AckSequence;
})
.ToArray();
Assert.Equal([41u, 42u, 43u], acked);
Assert.Equal(["first", "second", "third"], messages);
// Retail never acks per packet: nothing goes out in response to
// inbound datagrams (the cumulative ack lives on the negotiated
// transport's 2.0 s sweep, and no transport was negotiated here).
Assert.Empty(transport.Sent);
}
private static byte[] BuildPacket(uint sequence) =>
PacketCodec.Encode(
private static byte[] BuildPacket(
uint sequence,
uint fragmentSequence,
string text)
{
byte[] message = BuildServerMessage(text);
byte[] body = new byte[MessageFragmentHeader.Size + message.Length];
int written = GameMessageFragment.WriteSingleFragment(
body,
fragmentSequence,
GameMessageGroup.UIQueue,
message);
return PacketCodec.Encode(
new PacketHeader
{
Sequence = sequence,
Flags = PacketHeaderFlags.None,
Flags = PacketHeaderFlags.BlobFragments,
},
ReadOnlySpan<byte>.Empty,
body.AsSpan(0, written),
outboundIsaac: null);
}
private static byte[] BuildServerMessage(string text)
{
var writer = new PacketWriter(64 + text.Length);
writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0
writer.WriteString16L(text);
writer.WriteUInt32(1); // ChatMessageType
return writer.ToArray();
}
private sealed class ScriptedTransport : IWorldSessionTransport
{