Campaign N slice N4 completes the AckNakScheduler NAK branch and closes the ACE cleartext-reject keystream hazard - the slice that makes S2C loss actually RECOVER. NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0): - One cleartext exact-flags RequestRetransmit per sweep behind the STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s and vice versa (landmine #7). - Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E); header Sequence borrowed from highestIDSent_ without incrementing; cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) - and a NAK never refreshes ACE's 60 s timeout. - Control-header rule decided once for BOTH ack and NAK: Time = the interval id, Iteration = the session iteration, matching retail's shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84). ACE reads neither field inbound. - Gate ticks now round instead of truncate: 0.6 has no exact double form, and truncation opened the strict gate exactly AT the boundary. RejectRetransmit reclaim (divergence register AD-51, ACE adaptation): - ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748) - the one place ACE breaks retail's gap-walk invariant that every missing id was word-bearing (retail cleartext always borrows live sequences). Unhandled, the gap walk parks a word for the reject's id and the inbound stream runs permanently one word ahead - the N2 desync class reintroduced through the reject path. - Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes the mis-park, shifts every later-drawn parked word down one position (per-word draw ordinals; ascending wrap-safe id <=> ascending draw order), and pools the excess word, consumed lowest-draw-order-first ahead of fresh ISAAC draws. Exact for any number of interleaved rejects in ANY arrival order - a plain reclaim FIFO is not: a reject arriving after a higher encrypted arrival crosses the parked chain, and two out-of-order rejects pool their excess words out of draw order (both orderings pinned by tests). - Reject BODY ids keep N2's discard: word-bearing server-side, consumed-in-place. The pool is provably empty against retail servers. N3 advisories folded (all five): honest transitional-state wording (the empty N3 NAK branch could silently disconnect a loopback session at ACE's 60 s timeout, witness [net-tick] acks/s=0), the ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0) citation, the FlowQueue::Empty pump-order wording (TransmitNaks -> TransmitAcks -> TransmitNewPackets with the interval increment LAST @ 0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the Time/Iteration rule above, and the stale WorldSession budget-break comment rewritten to the sweep reality. Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3 pins): strict-gate boundary, shared timestamp both directions, NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served retransmission round trip, five tracker reclaim proofs, the 130 s virtual prune -> fresh-sequence reject system test (victim abandoned, later traffic decodes, pool drains to zero), 10 s long-loss survival (NAKs on the gate cadence, zero acks, heal inside the window), and the capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero message loss both ways, ACE crypto headroom 256 at convergence, every ledger drained (cache at the single watermark entry - retail's Flush prunes STRICTLY below the ack). Full solution Release: 9,758 passed / 5 skipped. Connected world-lifecycle gate PASS (logs/connected-world-gate-20260729-150238); canonical nine-stop soak PASS (logs/connected-r6-soak-20260729-150856). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
493 lines
20 KiB
C#
493 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign N Slice N3 — the AckNakScheduler: retail's 2.0 s cumulative
|
|
/// <c>AckSequence</c> (<c>SharedNet::EnqueuePak @ 0x00543B10</c>, 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
|
|
/// (<c>ClientNet::ProcessConnection @ 0x00545450</c>,
|
|
/// <c>ReceiverData::timeStamp_</c> — campaign landmine #7).
|
|
/// </summary>
|
|
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<byte[]> 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
|
|
// =====================================================================
|
|
|
|
/// <summary>
|
|
/// ACE's dedup exemption (NetworkSession.cs:342-343) and watermark-skip
|
|
/// (:474-476) both require <c>Flags == AckSequence</c> 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).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ModelAcceptsAck_AtReusedSequence_WithoutAdvancingWatermark()
|
|
{
|
|
(AceSessionModel model, _) = CreateNegotiatedModel();
|
|
(ReliableTransport transport, VirtualClock clock, List<byte[]> 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<byte[]> 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<byte[]> 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)
|
|
// =====================================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<string>();
|
|
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
|
|
// =====================================================================
|
|
|
|
/// <summary>A full transport on a virtual clock; every emitted datagram
|
|
/// (game sends AND scheduler acks) lands in <c>Sent</c>.</summary>
|
|
private static (ReliableTransport Transport, VirtualClock Clock,
|
|
List<byte[]> Sent) CreateTransport()
|
|
{
|
|
var virtualClock = new VirtualClock();
|
|
var sent = new List<byte[]>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>Admit one encrypted sequenced arrival that must not drop.</summary>
|
|
private static void Admit(ReliableTransport transport, uint sequence)
|
|
{
|
|
InboundSequenceTracker.Admission admission =
|
|
transport.Inbound.Admit(sequence, encrypted: true);
|
|
Assert.False(admission.Drop);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pin the full 24-byte wire shape of the cumulative ack (campaign
|
|
/// §2.3): flags are an EQUALITY match on <c>AckSequence</c> — 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: <c>Time</c> = the
|
|
/// interval id at emission and <c>Iteration</c> = the session
|
|
/// iteration (retail's shared header build at 0x00547A84).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>An 8-byte message body whose first byte is a test marker.</summary>
|
|
private static byte[] MakeMessage(byte marker) =>
|
|
new byte[] { marker, 0x11, 0x22, 0x33, 0x00, 0x00, 0x00, 0x00 };
|
|
|
|
private static IsaacRandom MakeIsaac(uint seed)
|
|
{
|
|
Span<byte> seedBytes = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
|
|
return new IsaacRandom(seedBytes);
|
|
}
|
|
|
|
/// <summary>A negotiated ACE double, matching the
|
|
/// OutboundReliableTransportTests fixture: LoginRequest → ConnectRequest
|
|
/// (discarded) → ConnectResponse → immediate first TimeSync (discarded;
|
|
/// S2C seq 2).</summary>
|
|
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<bool> 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();
|
|
}
|
|
}
|