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:
parent
19bfb8477d
commit
0265cc4236
9 changed files with 797 additions and 127 deletions
|
|
@ -254,7 +254,7 @@ verbatim in each implementer prompt.
|
|||
| N0 | complete | `7e9134b4` + `e3958610` | ACE-behaviour double + virtual clock + lossy link; the review fix-up added the `Session.CheckState` inbound gate, faithful `SendBundle` coalescing/splitting, two-phase termination, an ACE-loose C2S fragment parse, and ACE's MessageBuffer edge cases. 687 Core.Net tests green. N1 folded in the re-review's `ProcessFragment` two-branch split (existing-buffer checks Complete; new-buffer parks without checking — the zero-count buffer stays parked). |
|
||||
| N1 | complete | `43e60a69` | Outbound sent-packet cache + resend on NAK (`Transport/`: `TransportClock`, `SequenceMath`, `SentPacketStore`, `OutboundFlowQueue`, `ReliableTransport`, `TransportStats`); `PacketCodec.FinalizeInPlace` sealed-checksum overload; `WorldSession` sweep in Tick + both handshake pump loops; TS-57 filed, TS-27 narrowed to inbound-only. **Fable review PASS.** Advisories: fresh sends keep `Time=0` (byte-identical wire; retail stamps the interval on every packet and ACE ignores the field — N3 folds the retail stamp in with the cadence work); the stale-NAK single-redundant-resend window is shared with retail (pending prunes at flush, after transmit — same frame order as `RecipientData::UseTime`). |
|
||||
| N2 | complete | `46d209d0` — **Fable review PASS** (tracker/codec-split/admission verified pre-commit against retail rules; gates lifecycle+nine-stop both PASS) | Inbound sequence-aligned ISAAC + NAK set (`Transport/InboundSequenceTracker`: watermark, sanity window, duplicate/parked-key path, sequence-ordered gap-walk pre-draw, verify-failure re-park, RejectRetransmit abandonment — `ProcessNewSeqNum @ 0x00544690`, `ProcessNewestSeqNum @ 0x00541930`, `SeqIDSanityCheck @ 0x00543A20`, `AddNakked @ 0x00549240`, `HandleEmptyAck @ 0x005448F0`); `PacketCodec` split into keystream-free `TryParseBorrowed` + `VerifyChecksum` (`TryDecodeBorrowed` deleted); `RejectRetransmit` ids exposed on both optional-header decoders; `ReliableTransport` now owns both keystreams; stats gained `InboundDupsDropped`/`InboundSanityDrops`/`ChecksumFailures`/`KeysParked`. Watermark init 1 is the AD-50 ACE adaptation (retail zero-init vs ACE's re-prime dance — first encrypted S2C is sequence 2; holtburger api.rs:30 agrees); pinned by the clean-lifecycle conformance test (zero NAKs, min encrypted S2C sequence == 2). 716 Core.Net tests green. **N3/N4 handoff note:** the interim per-packet reflex ack acks the ARRIVING sequence even while a gap is parked, so ACE prunes the lost id from its S2C cache (`AcknowledgeSequence` strictly-below) before N4 can NAK it — message-level recovery of a real loss needs N3's retail NAK-xor-ack sweep (§2.3's mutual exclusivity is load-bearing). Also noted for N4: ACE's `RejectRetransmit` consumes a fresh CLEARTEXT sequence via FlushPackets (no keystream word), so the client's gap walk parks a word for an id that never had one server-side — a real retail-vs-ACE incompatibility to resolve in N4's design (retail never assigns new sequences to cleartext). |
|
||||
| N3 | pending | — | |
|
||||
| N3 | complete | — (SHA recorded at N4 kickoff) | AckNakScheduler + 2.0 s cumulative ack (`Transport/AckNakScheduler`): ONE shared timestamp (`ReceiverData::timeStamp_` @ +0x10) arbitrating NAK-xor-ack per sweep (`ClientNet::ProcessConnection @ 0x00545450`); the ack is one cleartext exact-flags `AckSequence` carrying the tracker's `highestIDReceived_` behind the >= 2.0 s gate (`SharedNet::EnqueuePak @ 0x00543B10` — the binary's only 0x4000 construction site), armed at connection birth (`ReceiverData::Init @ 0x00548EF0`). The Phase 4.9 per-packet reflex ack and `WorldSession.SendAck` are DELETED; the `[net-tick]` acks/s probe now reads `Stats.AcksSent`. Sweep order per `FlowQueue::Empty @ 0x00548A20`: interval clock, NAK/ack arbitration, pending resends, prune. **N3 transitional state:** a non-empty NAK set suppresses the ack and emits NOTHING (no NAK yet) — safe for exactly one slice on loopback; N4 completes the branch. **N1 advisory retired (fold-in):** fresh reliable sends now stamp `Header.Time` = the current interval id (`FlowQueue::TransmitNewPackets @ 0x00547A60`, header build at 0x00547A84); ACE ignores inbound `Header.Time`, so the wire is unaffected. New `WorldSession.TransportClockSource` seam drives the gate on virtual time. 723 Core.Net tests green (keepalive property proven: a quiet session's 2 s acks refresh ACE's 60 s deadline across a 120 s virtual horizon; storm collapse: a 50-packet flood → ONE ack; the model accepts the reused-sequence ack without advancing its watermark). Connected lifecycle + canonical nine-stop gates PASS. |
|
||||
| N4 | pending | — | |
|
||||
| N5 | pending | — | |
|
||||
| N6 | pending | — | |
|
||||
|
|
|
|||
168
src/AcDream.Core.Net/Transport/AckNakScheduler.cs
Normal file
168
src/AcDream.Core.Net/Transport/AckNakScheduler.cs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N3: retail's per-frame ack/NAK arbitration
|
||||
/// (<c>ClientNet::ProcessConnection @ 0x00545450</c>), replacing the
|
||||
/// Phase 4.9 per-packet reflex ack. Retail never acks per packet —
|
||||
/// <c>SharedNet::EnqueuePak @ 0x00543B10</c> is the ONLY
|
||||
/// <c>AckSequence</c> (0x4000) construction site in the whole binary, and
|
||||
/// it fires from this sweep alone.
|
||||
///
|
||||
/// <para>
|
||||
/// The two branches are mutually exclusive on ONE shared timestamp
|
||||
/// (<c>ReceiverData::timeStamp_</c> @ +0x10 — campaign landmine #7):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>NAK set non-empty → the NAK branch
|
||||
/// (<c>SharedNet::EnqueueNaks @ 0x00543BD0</c>, 0.6 s gate on the SAME
|
||||
/// timestamp) and NO ack this sweep. N4 emits the
|
||||
/// <c>RequestRetransmit</c> here; in N3 the branch exists but emits
|
||||
/// NOTHING — parked NAKs mean neither ack nor NAK goes out. That
|
||||
/// transitional state is safe for exactly one slice: on loopback gates
|
||||
/// nothing creates stray parked ids, and with no NAK emission ACE never
|
||||
/// produces the fresh-cleartext RejectRetransmit wrinkle the N2 ledger
|
||||
/// row records. N4 completes the branch.</item>
|
||||
/// <item>Else, when <c>now − sharedTimestamp ≥ 2.0 s</c>: ONE cumulative
|
||||
/// <c>AckSequence</c> carrying the tracker's <c>highestIDReceived_</c>
|
||||
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>), then
|
||||
/// <c>sharedTimestamp = now</c>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The gate is armed at construction: retail's <c>ReceiverData::Init</c>
|
||||
/// (@ 0x00548EF0, the <c>timeStamp_ = Timer::cur_time</c> store at
|
||||
/// 0x00548F46) stamps the shared timestamp at connection birth, so the
|
||||
/// first cumulative ack goes out 2.0 s after negotiation — the same shape
|
||||
/// as ACE's own <c>sendAck</c> arming (NetworkSession.cs:54-55). Do NOT
|
||||
/// shorten the gate: ACE's S2C cache holds 120 s and ACE's own ack cadence
|
||||
/// is the same 2 s.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wire shape (campaign §2.3 / §4 standalone-control design AP-125):
|
||||
/// flags EXACTLY <see cref="PacketHeaderFlags.AckSequence"/> — an equality,
|
||||
/// never an OR (landmine #5: ACE's dedup exemption at NetworkSession.cs:342-343
|
||||
/// and the watermark-skip at :474-476 both require the exact value) —
|
||||
/// cleartext (no ISAAC word), 4-byte little-endian body, header
|
||||
/// <c>Sequence</c> borrowed from <c>highestIDSent_</c> without incrementing,
|
||||
/// <c>Id</c> = the session client id, <c>Time</c>/<c>Iteration</c> zero.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Single-threaded like the rest of the transport: <see cref="Sweep"/>
|
||||
/// runs only from <see cref="ReliableTransport.Sweep"/> on the session's
|
||||
/// frame thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class AckNakScheduler
|
||||
{
|
||||
/// <summary>Retail's cumulative-ack gate
|
||||
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>, the x87 compare against
|
||||
/// 2.0 at 0x00543B32).</summary>
|
||||
public const double AckGateSeconds = 2.0;
|
||||
|
||||
private readonly InboundSequenceTracker _inbound;
|
||||
private readonly OutboundFlowQueue _outbound;
|
||||
private readonly TransportStats _stats;
|
||||
private readonly DatagramSendDelegate _send;
|
||||
private readonly ushort _sessionClientId;
|
||||
private readonly long _ackGateTicks;
|
||||
|
||||
/// <summary>THE shared timestamp (<c>ReceiverData::timeStamp_</c>
|
||||
/// @ +0x10). N4's 0.6 s NAK gate reads and writes this exact field —
|
||||
/// never introduce a second timestamp (landmine #7): a NAK delays the
|
||||
/// next ack and vice versa.</summary>
|
||||
private long _sharedTimestamp;
|
||||
|
||||
public AckNakScheduler(
|
||||
TransportClock clock,
|
||||
InboundSequenceTracker inbound,
|
||||
OutboundFlowQueue outbound,
|
||||
ushort sessionClientId,
|
||||
TransportStats stats,
|
||||
DatagramSendDelegate send)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
ArgumentNullException.ThrowIfNull(inbound);
|
||||
ArgumentNullException.ThrowIfNull(outbound);
|
||||
ArgumentNullException.ThrowIfNull(stats);
|
||||
ArgumentNullException.ThrowIfNull(send);
|
||||
|
||||
_inbound = inbound;
|
||||
_outbound = outbound;
|
||||
_sessionClientId = sessionClientId;
|
||||
_stats = stats;
|
||||
_send = send;
|
||||
_ackGateTicks = (long)(AckGateSeconds * clock.Frequency);
|
||||
// ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time at
|
||||
// connection birth: the gate starts armed, first ack at +2.0 s.
|
||||
_sharedTimestamp = clock.GetTimestamp();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One per-frame arbitration pass (<c>ClientNet::ProcessConnection
|
||||
/// @ 0x00545450</c>: <c>m_SeqIDsWeNAKed._currNum != 0 ? EnqueueNaks
|
||||
/// : EnqueuePak</c>). <paramref name="now"/> is the transport clock's
|
||||
/// current timestamp, sampled once by the caller.
|
||||
/// </summary>
|
||||
public void Sweep(long now)
|
||||
{
|
||||
if (_inbound.NakCount > 0)
|
||||
{
|
||||
// The NAK branch (SharedNet::EnqueueNaks @ 0x00543BD0, 0.6 s
|
||||
// gate on _sharedTimestamp). N4 emits the RequestRetransmit
|
||||
// here; until then parked NAKs suppress the ack and nothing
|
||||
// goes out this sweep — see the class doc for why that
|
||||
// transitional state is safe for exactly this slice. The
|
||||
// timestamp is NOT touched: only an actual emission stamps it.
|
||||
return;
|
||||
}
|
||||
|
||||
// SharedNet::EnqueuePak @ 0x00543B10 — proceed when the elapsed
|
||||
// time is NOT less than 2.0 (the `& 1` x87 status test at
|
||||
// 0x00543B3D), i.e. now − sharedTimestamp ≥ 2.0 s.
|
||||
if (now - _sharedTimestamp < _ackGateTicks)
|
||||
return;
|
||||
|
||||
EmitCumulativeAck();
|
||||
_sharedTimestamp = now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build and send the one cumulative <c>AckSequence</c>
|
||||
/// (<c>SharedNet::EnqueuePak @ 0x00543B10</c>: mask 0x4000, 4-byte
|
||||
/// payload = <c>highestIDReceived_</c>), byte-shaped exactly like the
|
||||
/// pre-N3 reflex ack except the VALUE is the cumulative watermark.
|
||||
/// </summary>
|
||||
private void EmitCumulativeAck()
|
||||
{
|
||||
Span<byte> datagram = stackalloc byte[PacketHeader.Size + sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
datagram.Slice(PacketHeader.Size),
|
||||
_inbound.HighestIdReceived);
|
||||
|
||||
var header = new PacketHeader
|
||||
{
|
||||
// Borrow the last reliable sequence without incrementing —
|
||||
// the ack is not part of the reliable stream. ACE accepts it
|
||||
// at the reused sequence via the exact-flags dedup exemption
|
||||
// (NetworkSession.cs:342-343) and skips the watermark advance
|
||||
// (:474-476).
|
||||
Sequence = _outbound.HighestIdSent,
|
||||
Flags = PacketHeaderFlags.AckSequence,
|
||||
Id = _sessionClientId,
|
||||
};
|
||||
|
||||
int datagramLength = PacketCodec.FinalizeInPlace(
|
||||
header,
|
||||
datagram,
|
||||
bodyLength: sizeof(uint),
|
||||
optionalLength: sizeof(uint),
|
||||
outboundIsaac: null);
|
||||
_send(datagram.Slice(0, datagramLength));
|
||||
_stats.AcksSent++;
|
||||
}
|
||||
}
|
||||
|
|
@ -117,9 +117,13 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
/// 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.
|
||||
/// Flags <c>BlobFragments|EncryptedChecksum</c>, session client id, one
|
||||
/// ISAAC word, and — the N3 fold-in of the N1 review advisory —
|
||||
/// <c>Time</c> = the current interval id: retail stamps
|
||||
/// <c>CurLocalInterval_.intervalID_</c> on every fresh packet
|
||||
/// (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, the header build
|
||||
/// at 0x00547A84). ACE never reads inbound <c>Header.Time</c>
|
||||
/// (campaign §3), so wire compatibility is unaffected.
|
||||
/// </summary>
|
||||
public void SendGameMessage(
|
||||
ReadOnlySpan<byte> gameMessageBody,
|
||||
|
|
@ -142,6 +146,7 @@ internal sealed class OutboundFlowQueue : IDisposable
|
|||
Flags = PacketHeaderFlags.BlobFragments
|
||||
| PacketHeaderFlags.EncryptedChecksum,
|
||||
Id = _sessionClientId,
|
||||
Time = _clock.IntervalId,
|
||||
};
|
||||
int datagramLength = PacketCodec.FinalizeInPlace(
|
||||
header,
|
||||
|
|
|
|||
|
|
@ -6,19 +6,20 @@ 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), 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.
|
||||
/// inbound sequence tracker (N2), the ack/NAK scheduler (N3), and the
|
||||
/// unconditional counters.
|
||||
///
|
||||
/// <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).
|
||||
/// clock, arbitrate NAK-xor-ack, 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; ACE needs acks during the character-list /
|
||||
/// enter-world floods, and the scheduler's ~2 s cadence there matches ACE's
|
||||
/// own), gated on transport negotiation (ACE's <c>Session.CheckState</c>
|
||||
/// discards early control traffic).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ReliableTransport : IDisposable
|
||||
|
|
@ -32,6 +33,11 @@ internal sealed class ReliableTransport : IDisposable
|
|||
/// queue at ISAAC-seeding time so both keystreams share one owner.</summary>
|
||||
public InboundSequenceTracker Inbound { get; }
|
||||
|
||||
/// <summary>N3: retail's NAK-xor-ack sweep arbitration on the one
|
||||
/// shared timestamp (<c>ClientNet::ProcessConnection @ 0x00545450</c>);
|
||||
/// owns the 2.0 s cumulative <c>AckSequence</c>.</summary>
|
||||
public AckNakScheduler Scheduler { get; }
|
||||
|
||||
public TransportStats Stats { get; }
|
||||
|
||||
public ReliableTransport(
|
||||
|
|
@ -52,24 +58,34 @@ internal sealed class ReliableTransport : IDisposable
|
|||
send,
|
||||
pool);
|
||||
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
|
||||
Scheduler = new AckNakScheduler(
|
||||
Clock,
|
||||
Inbound,
|
||||
Outbound,
|
||||
sessionClientId,
|
||||
Stats,
|
||||
send);
|
||||
Stats.CacheDepthSource = () => Outbound.CacheDepth;
|
||||
}
|
||||
|
||||
/// <summary>Last reliable sequence on the wire — the value unsequenced
|
||||
/// control packets (the reflex ack) borrow without incrementing.</summary>
|
||||
/// control packets (the cumulative 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).
|
||||
/// One transport pump: interval clock forward, NAK-xor-ack arbitration,
|
||||
/// pending NAKed resends out, acked cache entries pruned. Pump order per
|
||||
/// retail <c>FlowQueue::Empty @ 0x00548A20</c>: the interval clock, then
|
||||
/// the control-packet arbitration (<c>ClientNet::ProcessConnection
|
||||
/// @ 0x00545450</c> enqueues NAKs-or-ack before the flow queue drains),
|
||||
/// then retransmits, then 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();
|
||||
Scheduler.Sweep(Clock.GetTimestamp());
|
||||
Outbound.TransmitPendingResends();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ internal sealed class TransportStats
|
|||
/// pre-draw) plus one per checksum-failure re-park.</summary>
|
||||
public long KeysParked;
|
||||
|
||||
/// <summary>N3: cumulative <c>AckSequence</c> packets emitted by the
|
||||
/// 2.0 s sweep (<c>SharedNet::EnqueuePak @ 0x00543B10</c> — retail's
|
||||
/// only ack construction site; there is no per-packet ack).</summary>
|
||||
public long AcksSent;
|
||||
|
||||
/// <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>
|
||||
|
|
|
|||
|
|
@ -690,6 +690,16 @@ public sealed class WorldSession : IDisposable
|
|||
/// conformance/loss suites. Null before negotiation.</summary>
|
||||
internal ReliableTransport? Transport => _transport;
|
||||
|
||||
/// <summary>
|
||||
/// N3 test seam: injectable monotonic source for the transport clock so
|
||||
/// the conformance suite can drive the 2.0 s cumulative-ack gate (and
|
||||
/// the 0.5 s interval counter) on virtual time. Must be set BEFORE
|
||||
/// <see cref="Connect"/> (the transport is born there). Null →
|
||||
/// production <see cref="Stopwatch"/> timing.
|
||||
/// </summary>
|
||||
internal (Func<long> GetTimestamp, long Frequency)? TransportClockSource
|
||||
{ get; set; }
|
||||
|
||||
// Movement sequence counters — echoed back in every MoveToState and
|
||||
// AutonomousPosition so the server can detect stale/reordered packets.
|
||||
// Initialized from CreateObject PhysicsData timestamps, updated by
|
||||
|
|
@ -890,19 +900,26 @@ public sealed class WorldSession : IDisposable
|
|||
// generation into connection-level control packets, including the
|
||||
// final disconnect. ACE currently emits iteration 1.
|
||||
_sessionIteration = connectRequestIteration;
|
||||
// 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).
|
||||
// N1+N2+N3: the reliable transport is born at ISAAC-seeding time,
|
||||
// owning BOTH keystreams and the ack/NAK sweep. 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. Inbound: the
|
||||
// tracker owns the server keystream, the received watermark, and
|
||||
// the NAK set (campaign §2.2); its watermark starts 1 (see
|
||||
// InboundSequenceTracker.AceInitialWatermark). The scheduler's
|
||||
// 2.0 s cumulative-ack gate arms here, at connection birth
|
||||
// (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time).
|
||||
_transport = new ReliableTransport(
|
||||
new IsaacRandom(clientSeedBytes),
|
||||
new IsaacRandom(serverSeedBytes),
|
||||
_sessionClientId,
|
||||
datagram => _net.Send(datagram));
|
||||
datagram => _net.Send(datagram),
|
||||
clock: TransportClockSource is { } clockSource
|
||||
? new TransportClock(
|
||||
clockSource.GetTimestamp,
|
||||
clockSource.Frequency)
|
||||
: null);
|
||||
_transportNegotiated = true;
|
||||
|
||||
// Publish only after the receiver identity and crypto state are fully
|
||||
|
|
@ -1090,16 +1107,18 @@ public sealed class WorldSession : IDisposable
|
|||
// #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
|
||||
// directly as maxgap. _probeSendWindow/_probeAckWindow are Interlocked so
|
||||
// a hypothetical off-thread send can't corrupt the window counters (the
|
||||
// [net-out] tid field is what would prove such a send exists).
|
||||
// directly as maxgap. _probeSendWindow is Interlocked so a hypothetical
|
||||
// off-thread send can't corrupt the window counter (the [net-out] tid
|
||||
// field is what would prove such a send exists). Acks are counted by the
|
||||
// transport (Stats.AcksSent, incremented on the frame-thread sweep);
|
||||
// _probeAckSeenTotal is the last cumulative value the probe printed.
|
||||
private long _probeLastTickTs;
|
||||
private long _probeWindowStartTs;
|
||||
private long _probeMaxGapTicks;
|
||||
private int _probeProcessedWindow;
|
||||
private int _probeBudgetBreaks;
|
||||
private int _probeSendWindow;
|
||||
private int _probeAckWindow;
|
||||
private long _probeAckSeenTotal;
|
||||
// Probe-owned queue depth: the SingleReader channel's Reader.Count
|
||||
// throws NotSupportedException, so the net thread increments on
|
||||
// enqueue and the frame thread decrements on dequeue instead.
|
||||
|
|
@ -1134,7 +1153,9 @@ public sealed class WorldSession : IDisposable
|
|||
double windowSeconds = (double)windowTicks / Stopwatch.Frequency;
|
||||
double maxGapMs = _probeMaxGapTicks * 1000.0 / Stopwatch.Frequency;
|
||||
int sends = Interlocked.Exchange(ref _probeSendWindow, 0);
|
||||
int acks = Interlocked.Exchange(ref _probeAckWindow, 0);
|
||||
long ackTotal = _transport?.Stats.AcksSent ?? 0;
|
||||
long acks = ackTotal - _probeAckSeenTotal;
|
||||
_probeAckSeenTotal = ackTotal;
|
||||
Console.WriteLine(
|
||||
$"[net-tick] in/s={_probeProcessedWindow / windowSeconds:F0}"
|
||||
+ $" q={Volatile.Read(ref _probeInboundDepth)}"
|
||||
|
|
@ -1391,9 +1412,12 @@ public sealed class WorldSession : IDisposable
|
|||
// acceptance, before any heavy render-thread message handling.
|
||||
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
|
||||
|
||||
// N1: consume the transport control surfaces FIRST, before the
|
||||
// reflex ack below (which still fires unchanged this slice; the
|
||||
// AckNakScheduler replaces it in N3).
|
||||
// N1: consume the transport control surfaces. Acknowledging the
|
||||
// OTHER direction is not done here: N3 deleted the Phase 4.9
|
||||
// per-packet reflex ack — retail never acks per packet
|
||||
// (SharedNet::EnqueuePak @ 0x00543B10 is the binary's only 0x4000
|
||||
// construction site). The AckNakScheduler emits ONE cumulative
|
||||
// AckSequence per 2.0 s from the SweepTransport pump instead.
|
||||
if (_transport is { } transport)
|
||||
{
|
||||
// Server NAK (RequestRetransmit 0x1000): merge the requested
|
||||
|
|
@ -1426,19 +1450,6 @@ public sealed class WorldSession : IDisposable
|
|||
transport.Outbound.OnAckSequence(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
|
||||
// ack queued back; not periodic). Without it, ACE drops the session
|
||||
// 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).
|
||||
if (serverHeader.Sequence > 0
|
||||
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
|
||||
{
|
||||
SendAck(serverHeader.Sequence);
|
||||
}
|
||||
|
||||
// Phase G.1: propagate TimeSync-flagged server time to anyone who
|
||||
// needs it (sky/day-night lerp in particular). Server sends this
|
||||
// periodically — no explicit opcode, just the header flag.
|
||||
|
|
@ -2469,63 +2480,6 @@ public sealed class WorldSession : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging
|
||||
/// <paramref name="serverPacketSequence"/>. This is a cleartext control
|
||||
/// packet (no EncryptedChecksum) — the body is just the 4-byte server
|
||||
/// sequence number being acknowledged. The header re-uses the most
|
||||
/// recently sent client sequence (no increment) because acks aren't
|
||||
/// themselves part of the reliable stream the server tracks.
|
||||
///
|
||||
/// <para>
|
||||
/// Without sending these, ACE drops the session with
|
||||
/// <c>Network Timeout</c> after ~60s — and during that 60s the
|
||||
/// character appears to other clients as a stationary purple haze
|
||||
/// (loading state) because the server hasn't seen the client confirm
|
||||
/// any post-EnterWorld traffic.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Pattern ported from
|
||||
/// <c>references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack</c>
|
||||
/// and the receive-side trigger at
|
||||
/// <c>.../session/receive.rs::finalize_ordered_server_packet</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void SendAck(uint serverPacketSequence)
|
||||
{
|
||||
// 4-byte body: little-endian u32 of the server sequence we're acking.
|
||||
Span<byte> datagram = stackalloc byte[
|
||||
PacketHeader.Size + sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
datagram.Slice(PacketHeader.Size),
|
||||
serverPacketSequence);
|
||||
|
||||
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
|
||||
// ack headers. We mirror that — acks borrow the most recently issued
|
||||
// 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
|
||||
{
|
||||
Sequence = ackHeaderSequence,
|
||||
Flags = PacketHeaderFlags.AckSequence,
|
||||
Id = _sessionClientId,
|
||||
};
|
||||
|
||||
int datagramLength = PacketCodec.FinalizeInPlace(
|
||||
header,
|
||||
datagram,
|
||||
bodyLength: sizeof(uint),
|
||||
optionalLength: sizeof(uint),
|
||||
outboundIsaac: null);
|
||||
_net.Send(datagram.Slice(0, datagramLength));
|
||||
if (NetDiagnostics.ProbeNet)
|
||||
Interlocked.Increment(ref _probeAckWindow);
|
||||
}
|
||||
|
||||
private void Transition(State next)
|
||||
{
|
||||
if (CurrentState == next) return;
|
||||
|
|
|
|||
461
tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs
Normal file
461
tests/AcDream.Core.Net.Tests/Transport/AckNakSchedulerTests.cs
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
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 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::Init @ 0x00548EF0 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).
|
||||
clock.Advance(TimeSpan.FromSeconds(0.01));
|
||||
transport.Sweep();
|
||||
AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 3u);
|
||||
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);
|
||||
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.0 s elapses with the NAK set non-empty: the NAK branch owns the
|
||||
// sweep and (until N4 emits RequestRetransmit there) NOTHING goes
|
||||
// out — 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).
|
||||
clock.Advance(TimeSpan.FromSeconds(2.5));
|
||||
transport.Sweep();
|
||||
transport.Sweep();
|
||||
Assert.Empty(sent);
|
||||
Assert.Equal(0, transport.Stats.AcksSent);
|
||||
|
||||
// The missing packet arrives (late delivery), clearing the set —
|
||||
// the ack resumes at the next gate, which is long since due.
|
||||
Admit(transport, 3u);
|
||||
Assert.Equal(0, transport.Inbound.NakCount);
|
||||
transport.Sweep();
|
||||
AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 4u);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 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);
|
||||
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,
|
||||
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, <c>Time</c>/<c>Iteration</c> zero.
|
||||
/// </summary>
|
||||
private static void AssertAckShape(
|
||||
byte[] datagram,
|
||||
uint expectedSequence,
|
||||
uint expectedValue)
|
||||
{
|
||||
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((ushort)0, header.Time);
|
||||
Assert.Equal((ushort)0, 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -180,7 +180,9 @@ public sealed class OutboundReliableTransportTests
|
|||
Assert.Equal(
|
||||
PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
|
||||
originalHeader.Flags);
|
||||
Assert.Equal((ushort)0, originalHeader.Time);
|
||||
// N3 fold-in: fresh sends stamp the current interval id (the clock
|
||||
// starts at 1) — FlowQueue::TransmitNewPackets @ 0x00547A60.
|
||||
Assert.Equal((ushort)1, originalHeader.Time);
|
||||
|
||||
// 1.2 s later (interval id 1 → 3) the server NAKs sequence 2.
|
||||
virtualClock.Advance(TimeSpan.FromSeconds(1.2));
|
||||
|
|
@ -323,6 +325,39 @@ public sealed class OutboundReliableTransportTests
|
|||
Assert.True(stats.AcksConsumed >= 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N3 fold-in of the N1 review advisory: retail stamps
|
||||
/// <c>CurLocalInterval_.intervalID_</c> into <c>Header.Time</c> on every
|
||||
/// FRESH packet (<c>FlowQueue::TransmitNewPackets @ 0x00547A60</c>, the
|
||||
/// header build at 0x00547A84), and a resend re-stamps the CURRENT
|
||||
/// interval id (possibly newer than the fresh-send stamp). ACE never
|
||||
/// reads inbound <c>Header.Time</c>, so this is wire-cosmetic against
|
||||
/// ACE — but it is retail's behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FreshSend_StampsCurrentIntervalId_ResendRestampsNewer()
|
||||
{
|
||||
(OutboundFlowQueue queue, VirtualClock virtualClock,
|
||||
TransportClock clock, _, List<byte[]> sent) = CreateQueue();
|
||||
|
||||
// K interval ticks before the send: 2.5 s = 5 intervals, id 1 → 6.
|
||||
virtualClock.Advance(TimeSpan.FromSeconds(2.5));
|
||||
clock.Update();
|
||||
Assert.Equal((ushort)6, clock.IntervalId);
|
||||
queue.SendGameMessage(MakeMessage(0xA1), GameMessageGroup.UIQueue);
|
||||
Assert.Equal((ushort)6, PacketHeader.Unpack(Assert.Single(sent)).Time);
|
||||
|
||||
// The interval advances again; the resend carries the CURRENT id,
|
||||
// newer than the fresh-send stamp.
|
||||
virtualClock.Advance(TimeSpan.FromSeconds(1.0));
|
||||
clock.Update();
|
||||
Assert.Equal((ushort)8, clock.IntervalId);
|
||||
Nak(queue, 2u);
|
||||
sent.Clear();
|
||||
queue.TransmitPendingResends();
|
||||
Assert.Equal((ushort)8, PacketHeader.Unpack(Assert.Single(sent)).Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnAckSequence_IsWrapSafeMax_AndNeverRegresses()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue