using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net.Tests.Transport;
///
/// Campaign N Slice N3 — the AckNakScheduler: retail's 2.0 s cumulative
/// AckSequence (SharedNet::EnqueuePak @ 0x00543B10, the only
/// 0x4000 construction site in the binary) replacing the Phase 4.9
/// per-packet reflex ack, arbitrated NAK-xor-ack on ONE shared timestamp
/// (ClientNet::ProcessConnection @ 0x00545450,
/// ReceiverData::timeStamp_ — campaign landmine #7).
///
public sealed class AckNakSchedulerTests
{
private const uint ClientSeed = 0x11AA22BBu;
private const uint ServerSeed = 0x33CC44DDu;
private const uint ClientId = 0x1234u;
private const ushort SessionIteration = 0x0007;
private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
// =====================================================================
// The 2.0 s gate — SharedNet::EnqueuePak @ 0x00543B10
// =====================================================================
[Fact]
public void CumulativeAck_TwoSecondGate_CarriesWatermarkAtEmission()
{
(ReliableTransport transport, VirtualClock clock, List sent) =
CreateTransport();
Admit(transport, 2u);
Admit(transport, 3u);
// No ack before 2.0 s — the gate armed at transport construction
// (ReceiverData::SharedInit @ 0x00548EF0, reached from
// ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time).
transport.Sweep();
clock.Advance(TimeSpan.FromSeconds(1.99));
transport.Sweep();
Assert.Empty(sent);
// Exactly one at the boundary (the retail compare is >=, the x87
// `& 1` status test at 0x00543B3D). Header.Time carries the
// interval id at emission (N4 control-header rule): 2.0 s of
// 0.5 s intervals on top of the initial id 1 → 5.
clock.Advance(TimeSpan.FromSeconds(0.01));
transport.Sweep();
Assert.Equal((ushort)5, transport.Clock.IntervalId);
AssertAckShape(
Assert.Single(sent),
expectedSequence: 1u,
expectedValue: 3u,
expectedTime: transport.Clock.IntervalId);
Assert.Equal(1, transport.Stats.AcksSent);
// The gate reset: silent until the next 2.0 s elapses.
transport.Sweep();
clock.Advance(TimeSpan.FromSeconds(1.99));
transport.Sweep();
Assert.Single(sent);
// The watermark advanced between gates: the NEWER value rides —
// the ack always carries highestIDReceived_ AT EMISSION.
Admit(transport, 4u);
Admit(transport, 5u);
clock.Advance(TimeSpan.FromSeconds(0.01));
transport.Sweep();
Assert.Equal(2, sent.Count);
AssertAckShape(
sent[1],
expectedSequence: 1u,
expectedValue: 5u,
expectedTime: transport.Clock.IntervalId);
Assert.Equal(2, transport.Stats.AcksSent);
}
// =====================================================================
// Flags equality (landmine #5) — model-side acceptance
// =====================================================================
///
/// ACE's dedup exemption (NetworkSession.cs:342-343) and watermark-skip
/// (:474-476) both require Flags == AckSequence EXACTLY. The
/// emitted ack must be accepted at the reused client sequence WITHOUT
/// advancing ACE's watermark — any extra ORed bit would advance the
/// watermark past a live sequence and wedge the session (§3 row 3).
///
[Fact]
public void ModelAcceptsAck_AtReusedSequence_WithoutAdvancingWatermark()
{
(AceSessionModel model, _) = CreateNegotiatedModel();
(ReliableTransport transport, VirtualClock clock, List sent) =
CreateTransport();
// Two reliable client packets reach ACE: sequences 2, 3.
transport.Outbound.SendGameMessage(
MakeMessage(1), GameMessageGroup.UIQueue);
transport.Outbound.SendGameMessage(
MakeMessage(2), GameMessageGroup.UIQueue);
foreach (byte[] datagram in sent)
model.Receive(datagram);
Assert.Equal(3u, model.LastReceivedPacketSequence);
sent.Clear();
// The sweep's cumulative ack borrows sequence 3 — the last issued
// client sequence, not a fresh one.
clock.Advance(TimeSpan.FromSeconds(2));
transport.Sweep();
byte[] ack = Assert.Single(sent);
PacketHeader ackHeader = PacketHeader.Unpack(ack);
Assert.Equal(3u, ackHeader.Sequence);
Assert.Equal(
(uint)PacketHeaderFlags.AckSequence,
(uint)ackHeader.Flags);
model.Receive(ack);
Assert.Equal(0, model.DuplicateDropCount);
Assert.Equal(0, model.CrcDropCount);
Assert.Equal(0, model.StateDropCount);
// The watermark did NOT advance (:474-476 — exact-flags skip).
Assert.Equal(3u, model.LastReceivedPacketSequence);
}
// =====================================================================
// NAK-xor-ack mutual exclusivity (landmine #7) —
// ClientNet::ProcessConnection @ 0x00545450
// =====================================================================
[Fact]
public void ParkedNak_SuppressesTheAck_AckResumesWhenTheGapClears()
{
(ReliableTransport transport, VirtualClock clock, List sent) =
CreateTransport();
Admit(transport, 2u); // watermark 2, no gap
Admit(transport, 4u); // gap walk parks id 3
Assert.Equal(1, transport.Inbound.NakCount);
// 2.5 s elapses with the NAK set non-empty: the NAK branch owns the
// sweep — ONE RequestRetransmit goes out and never an ack while ids
// are parked (§2.3's mutual exclusivity; the N2 ledger row shows why
// acking here would let ACE prune the lost id from its S2C cache
// before the NAK). The immediate second sweep is silenced by the
// freshly stamped shared timestamp.
clock.Advance(TimeSpan.FromSeconds(2.5));
transport.Sweep();
transport.Sweep();
byte[] nak = Assert.Single(sent);
Assert.Equal(
(uint)PacketHeaderFlags.RequestRetransmit,
(uint)PacketHeader.Unpack(nak).Flags);
Assert.Equal(0, transport.Stats.AcksSent);
Assert.Equal(1, transport.Stats.NaksSent);
sent.Clear();
// The missing packet arrives (late delivery), clearing the set —
// the ack resumes once 2.0 s elapse past the NAK's stamp of the
// SHARED timestamp (landmine #7: a NAK delays the next ack).
Admit(transport, 3u);
Assert.Equal(0, transport.Inbound.NakCount);
transport.Sweep();
Assert.Empty(sent);
clock.Advance(TimeSpan.FromSeconds(2.0));
transport.Sweep();
AssertAckShape(
Assert.Single(sent),
expectedSequence: 1u,
expectedValue: 4u,
expectedTime: transport.Clock.IntervalId);
}
// =====================================================================
// Ack-storm collapse — retail never acks per packet
// =====================================================================
[Fact]
public void CreateObjectFlood_InsideOneWindow_CollapsesToOneAck()
{
(ReliableTransport transport, VirtualClock clock, List sent) =
CreateTransport();
// A simulated CreateObject flood: 50 sequenced arrivals across
// 1.0 s, the per-frame sweep interleaved. The pre-N3 reflex ack
// sent 50 acks — one per packet; retail sends NONE until the gate.
uint sequence = 2;
for (int i = 0; i < 50; i++)
{
Admit(transport, sequence++);
clock.Advance(TimeSpan.FromMilliseconds(20));
transport.Sweep();
}
Assert.Empty(sent);
// Crossing the 2.0 s gate: exactly ONE cumulative ack for the
// whole flood, carrying the final watermark.
clock.Advance(TimeSpan.FromSeconds(1.0));
transport.Sweep();
AssertAckShape(
Assert.Single(sent),
expectedSequence: 1u,
expectedValue: 51u,
expectedTime: transport.Clock.IntervalId);
Assert.Equal(1, transport.Stats.AcksSent);
}
// =====================================================================
// Conformance against the N0 ACE-behaviour double (real WorldSession)
// =====================================================================
///
/// The keepalive property the reflex ack used to provide, now proven
/// for the cumulative ack: a QUIET session (no game actions; the only
/// inbound is ACE's own TimeSync every 20 s and ack every 2 s) still
/// sends one cleartext ack per ~2 s, and each one refreshes ACE's 60 s
/// TimeoutDeadline (NetworkSession.cs:329-331) — the session survives
/// far past the 60 s horizon.
///
[Fact]
public void QuietSession_CumulativeAcksKeepAceAlive_PastThe60sHorizon()
{
var transport = new FakeAceTransport();
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
session.TransportClockSource =
(transport.Clock.GetTimestamp, transport.Clock.Frequency);
try
{
session.Connect(
"testaccount", "testpassword", TimeSpan.FromSeconds(10));
session.EnterWorld(0, TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
// 120 virtual seconds in 0.5 s frames — double ACE's timeout
// horizon. The model's Update checks its deadline every pump.
for (int frame = 0; frame < 240; frame++)
{
transport.Clock.Advance(TimeSpan.FromMilliseconds(500));
transport.PumpServer();
session.Tick();
Thread.Sleep(1);
}
Assert.False(transport.Model.IsTerminated);
Assert.Equal(
AceTerminationReason.None,
transport.Model.TerminationReason);
// The deadline is FRESH — refreshed within the last ack
// interval, not merely unexpired.
long margin = transport.Model.TimeoutDeadlineTimestamp
- transport.Clock.GetTimestamp();
Assert.True(
margin > TimeSpan.FromSeconds(50).Ticks,
$"TimeoutDeadline margin {margin} ticks — the acks are not refreshing it");
// ~60 acks expected over 120 s; ≥ 40 pins the ~2 s cadence
// without depending on frame phase.
long acksSent = session.Transport!.Stats.AcksSent;
Assert.True(
acksSent >= 40,
$"only {acksSent} cumulative acks over 120 virtual seconds");
Assert.Equal(0, transport.Model.CrcDropCount);
Assert.Equal(0, transport.Model.StateDropCount);
Assert.Equal(0, transport.Model.DuplicateDropCount);
Assert.Equal(0, session.Transport.Inbound.NakCount);
}
finally
{
session.Dispose();
}
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
}
///
/// Full lifecycle against the double: clean run completes, an in-world
/// S2C flood collapses to one ack at the next gate, the model timeout
/// never fires, and teardown is OUR graceful Disconnect — with zero
/// CRC/state/duplicate drops end to end.
///
[Fact]
public void FullLifecycle_CleanRun_FloodCollapses_GracefulTeardown()
{
var transport = new FakeAceTransport();
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
session.TransportClockSource =
(transport.Clock.GetTimestamp, transport.Clock.Frequency);
try
{
session.Connect(
"testaccount", "testpassword", TimeSpan.FromSeconds(10));
session.EnterWorld(0, TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
var messages = new List();
session.ServerMessageReceived += m => messages.Add(m.Message);
// An S2C flood inside one 2 s window: 20 messages, each pumped
// into its own sequenced packet (the pre-N3 reflex ack answered
// every one of them).
long acksBefore = session.Transport!.Stats.AcksSent;
for (int i = 0; i < 20; i++)
{
transport.Model.EnqueueGameMessage(
BuildServerMessage($"flood {i}"),
GameMessageGroup.UIQueue);
transport.PumpServer();
}
PumpUntil(session, () => messages.Count >= 20);
Assert.Equal(acksBefore, session.Transport.Stats.AcksSent);
// Exactly ONE cumulative ack at the next gate covers the
// whole flood.
transport.Clock.Advance(TimeSpan.FromSeconds(2));
session.Tick();
Assert.Equal(
acksBefore + 1,
session.Transport.Stats.AcksSent);
// A few more quiet gates keep flowing.
for (int frame = 0; frame < 10; frame++)
{
transport.Clock.Advance(TimeSpan.FromMilliseconds(500));
transport.PumpServer();
session.Tick();
Thread.Sleep(1);
}
Assert.False(transport.Model.IsTerminated);
Assert.Equal(0, transport.Model.CrcDropCount);
Assert.Equal(0, transport.Model.StateDropCount);
Assert.Equal(0, transport.Model.DuplicateDropCount);
Assert.Equal(256, transport.Model.Crypto.Headroom);
Assert.Equal(0, session.Transport.Inbound.NakCount);
}
finally
{
session.Dispose();
}
// The model terminated on OUR transport Disconnect — the 60 s
// timeout never fired.
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
Assert.True(transport.Model.IsTerminated);
Assert.Equal(
AceTerminationReason.PacketHeaderDisconnect,
transport.Model.TerminationReason);
}
// =====================================================================
// Fixture helpers
// =====================================================================
/// A full transport on a virtual clock; every emitted datagram
/// (game sends AND scheduler acks) lands in Sent.
private static (ReliableTransport Transport, VirtualClock Clock,
List Sent) CreateTransport()
{
var virtualClock = new VirtualClock();
var sent = new List();
var transport = new ReliableTransport(
MakeIsaac(ClientSeed),
MakeIsaac(ServerSeed),
(ushort)ClientId,
SessionIteration,
datagram => sent.Add(datagram.ToArray()),
new TransportClock(
virtualClock.GetTimestamp,
virtualClock.Frequency));
return (transport, virtualClock, sent);
}
/// Admit one encrypted sequenced arrival that must not drop.
private static void Admit(ReliableTransport transport, uint sequence)
{
InboundSequenceTracker.Admission admission =
transport.Inbound.Admit(sequence, encrypted: true);
Assert.False(admission.Drop);
}
///
/// Pin the full 24-byte wire shape of the cumulative ack (campaign
/// §2.3): flags are an EQUALITY match on AckSequence — the raw
/// uint compare fails if ANY extra bit is ORed in (landmine #5) —
/// cleartext (decodes with a null keystream), 4-byte little-endian
/// body carrying the watermark, borrowed header sequence, session
/// client id, and the N4 control-header rule: Time = the
/// interval id at emission and Iteration = the session
/// iteration (retail's shared header build at 0x00547A84).
///
private static void AssertAckShape(
byte[] datagram,
uint expectedSequence,
uint expectedValue,
ushort expectedTime)
{
Assert.Equal(PacketHeader.Size + sizeof(uint), datagram.Length);
PacketHeader header = PacketHeader.Unpack(datagram);
Assert.Equal(
(uint)PacketHeaderFlags.AckSequence,
(uint)header.Flags);
Assert.Equal(expectedSequence, header.Sequence);
Assert.Equal((ushort)ClientId, header.Id);
Assert.Equal(expectedTime, header.Time);
Assert.Equal(SessionIteration, header.Iteration);
Assert.Equal((ushort)sizeof(uint), header.DataSize);
Assert.Equal(
expectedValue,
BinaryPrimitives.ReadUInt32LittleEndian(
datagram.AsSpan(PacketHeader.Size)));
// Cleartext: verifies additively with no ISAAC word.
PacketCodec.PacketDecodeResult decoded =
PacketCodec.TryDecode(datagram, inboundIsaac: null);
Assert.True(decoded.IsOk, decoded.Error.ToString());
Assert.Equal(expectedValue, decoded.Packet!.Optional.AckSequence);
}
/// An 8-byte message body whose first byte is a test marker.
private static byte[] MakeMessage(byte marker) =>
new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 };
private static IsaacRandom MakeIsaac(uint seed)
{
Span seedBytes = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
return new IsaacRandom(seedBytes);
}
/// A negotiated ACE double, matching the
/// OutboundReliableTransportTests fixture: LoginRequest → ConnectRequest
/// (discarded) → ConnectResponse → immediate first TimeSync (discarded;
/// S2C seq 2).
private static (AceSessionModel Model, VirtualClock Clock)
CreateNegotiatedModel()
{
var clock = new VirtualClock();
var model = new AceSessionModel(
clock, ClientSeed, ServerSeed, ClientId, Cookie);
model.LoginRequestReceived += model.SendConnectRequest;
byte[] login = PacketCodec.Encode(
new PacketHeader { Flags = PacketHeaderFlags.LoginRequest },
LoginRequest.Build("testaccount", "testpassword", 1234),
outboundIsaac: null);
model.Receive(login);
model.Update();
model.TakePendingDatagrams();
byte[] cookieBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(cookieBody, Cookie);
byte[] connectResponse = PacketCodec.Encode(
new PacketHeader
{ Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse },
cookieBody,
outboundIsaac: null);
model.Receive(connectResponse);
model.Update();
model.TakePendingDatagrams();
return (model, clock);
}
private static void PumpUntil(WorldSession session, Func condition)
{
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition() && DateTime.UtcNow < deadline)
{
session.Tick();
Thread.Sleep(5);
}
Assert.True(condition(), "condition not reached before the deadline");
}
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();
}
}