Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.
New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
`intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
@ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
@ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
@ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
store, the wrap-safe sorted dedup pending-resend list
(FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
Cache commit happens AFTER a successful send
(FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
NAK ids[0] folds into the watermark as retail's implicit cumulative ack
(RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
with fragments), Time = current interval id, Sequence/Id/Iteration/
DataSize verbatim, checksum = fresh header hash + stored sealed checksum
(FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
original ISAAC key rides inside the sealed value - no new keystream word
is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
this slice.
- TransportStats: unconditional counters (ResendsSent,
NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.
PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.
WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.
Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.
N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.
Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.
Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1500 lines
64 KiB
C#
1500 lines
64 KiB
C#
using System.Buffers.Binary;
|
||
using AcDream.Core.Net.Cryptography;
|
||
using AcDream.Core.Net.Messages;
|
||
using AcDream.Core.Net.Packets;
|
||
|
||
namespace AcDream.Core.Net.Tests.Transport;
|
||
|
||
/// <summary>
|
||
/// Termination causes the double can hit, mirroring ACE's
|
||
/// <c>SessionTerminationReason</c> names for the modeled paths.
|
||
/// </summary>
|
||
internal enum AceTerminationReason
|
||
{
|
||
None,
|
||
/// <summary>NetworkSession.cs:312-315 — client sent a Disconnect header.</summary>
|
||
PacketHeaderDisconnect,
|
||
/// <summary>NetworkSession.cs:318-321 — client sent NetErrorDisconnect.</summary>
|
||
ClientSentNetworkErrorDisconnect,
|
||
/// <summary>NetworkSession.cs:393-397 — sequence gap beyond the crypto search window.</summary>
|
||
AbnormalSequenceReceived,
|
||
/// <summary>TimeoutTick (NetworkSession.cs:88, Session.cs:140-144) expired — every ACE transport death is silence.</summary>
|
||
NetworkTimeout,
|
||
}
|
||
|
||
/// <summary>
|
||
/// ACE's <c>SessionState</c> (Network/Enum/SessionState.cs), reduced to the
|
||
/// three states a transport-level conversation can reach. The gate that reads
|
||
/// it is <c>Session.CheckState</c> (Session.cs:93-105).
|
||
///
|
||
/// <para>
|
||
/// Not modeled: <c>WorldConnected</c> (set by CharacterHandler.cs:260 — it
|
||
/// gates gameplay handlers, never the transport pipeline) and
|
||
/// <c>TerminationStarted</c> (Session.cs:128; only reachable with
|
||
/// <c>PendingTermination</c> already set, and the only code reading it,
|
||
/// Session.cs:136-137, is unreachable while it is).
|
||
/// </para>
|
||
/// </summary>
|
||
internal enum AceSessionState
|
||
{
|
||
/// <summary>Pre-login. CheckState drops Ack/TimeSync/Echo/Flow here.</summary>
|
||
AuthLoginRequest,
|
||
/// <summary>ConnectRequest sent, waiting for the ConnectResponse (AuthenticationHandler.cs:232).</summary>
|
||
AuthConnectResponse,
|
||
/// <summary>Handshake complete (NetworkManager.cs:77).</summary>
|
||
AuthConnected,
|
||
}
|
||
|
||
/// <summary>ACE's <c>SessionTerminationPhase</c> (Network/Enum/SessionTerminationPhase.cs).
|
||
/// <c>WorldManagerWorkCompleted</c> is a post-drop bookkeeping marker
|
||
/// (NetworkManager.cs:369) with no transport-visible effect and is not
|
||
/// modeled.</summary>
|
||
internal enum AceTerminationPhase
|
||
{
|
||
/// <summary>Session.cs:126-131 — the ~2 s window in which inbound and outbound still run.</summary>
|
||
Initialized,
|
||
/// <summary>Session.cs:130-131 — the window elapsed; WorldManager may now DropSession.</summary>
|
||
SessionWorkCompleted,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Transport-free model of ACE's per-connection <c>Session</c> +
|
||
/// <c>NetworkSession</c> receive + send behavior, operating on raw datagrams
|
||
/// (byte[]). This is the Campaign N test double: slices N1-N5 are graded
|
||
/// against it, so every rule carries its citation into
|
||
/// <c>references/ACE/Source/ACE.Server/Network/NetworkSession.cs</c> (or the
|
||
/// named ACE file). It deliberately reproduces ACE's raw (wrap-unsafe)
|
||
/// sequence comparisons and the exact-equality flag checks — do NOT "fix"
|
||
/// them; they are the environment acdream must survive.
|
||
///
|
||
/// <para>
|
||
/// Time comes exclusively from an injected <see cref="VirtualClock"/> — no
|
||
/// wall clock anywhere. The model is single-threaded by design; callers
|
||
/// (see <see cref="FakeAceTransport"/>) serialize access.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Not an independent wire oracle.</b> C2S CRC verification reuses
|
||
/// acdream's own <see cref="PacketHeaderOptional"/> to parse and hash the
|
||
/// optional-header section, so the double is blind to any bug SHARED by our
|
||
/// encoder and our parser: an optional section we write wrong and read back
|
||
/// wrong still checksums correctly here, while real ACE would drop it. Two
|
||
/// asymmetries against ACE's <c>PacketHeaderOptional.Unpack</c> are known and
|
||
/// deliberate:
|
||
/// <list type="bullet">
|
||
/// <item>ACE has NO inbound ConnectRequest branch (PacketHeaderOptional.cs:30-124
|
||
/// goes straight from AckSequence to LoginRequest); acdream decodes a
|
||
/// 32-byte section (PacketHeaderOptional.cs:139-150) because we are the
|
||
/// client. A C2S packet carrying that flag would hash differently on
|
||
/// real ACE.</item>
|
||
/// <item>ACE hashes-but-does-not-advance the reader for LoginRequest
|
||
/// (PacketHeaderOptional.cs:78), WorldLoginRequest (:86) and
|
||
/// ConnectResponse (:94); acdream advances past WorldLoginRequest and
|
||
/// ConnectResponse (PacketHeaderOptional.cs:130-133, :152-155). The
|
||
/// hashed bytes match; only the fragment-loop start offset differs, and
|
||
/// only for packets carrying those flags plus fragments (none exist).</item>
|
||
/// </list>
|
||
/// The independent oracle for optional-header wire layout stays the live-ACE
|
||
/// connected gate, not this double.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Intentional simplifications, none affecting the pinned rules:
|
||
/// <list type="bullet">
|
||
/// <item>The initial timeout horizon is the 60 s in-world value; ACE's
|
||
/// 15 s pre-auth window (NetworkSession.cs:102-103) is not modeled.
|
||
/// Timeout expiry is checked in <see cref="Update"/> (ACE checks
|
||
/// <c>TimeoutTick</c> from Session.TickOutbound, Session.cs:140-144).</item>
|
||
/// <item>The 5 ms inter-bundle pacing delay (NetworkSession.cs:30, :244)
|
||
/// is not modeled — it is send pacing, not protocol behavior, and
|
||
/// would deadlock a virtual clock that only tests advance.</item>
|
||
/// <item>Ack/TimeSync/EchoResponse/message-bundle emission is gated on the
|
||
/// handshake being complete (ACE cannot address S2C traffic before it
|
||
/// knows the endpoint; pre-handshake the timers cannot have fired in
|
||
/// practice). Queued raw packets (ConnectRequest, NAK,
|
||
/// RejectRetransmit) flush regardless, like ACE's FlushPackets.</item>
|
||
/// <item><c>VerifyEcho</c>'s speed-hack detector (NetworkSession.cs:593-647)
|
||
/// is not modeled: it can log off a player but never terminates the
|
||
/// transport session.</item>
|
||
/// <item>The inbound retransmit/reject list cap of 1024 ids that acdream's
|
||
/// parser enforces (PacketHeaderOptional.cs:82, :96) does not exist in
|
||
/// ACE (PacketHeaderOptional.cs:35-60). It is unreachable rather than
|
||
/// wrong: ACE reads into a 1024-byte buffer (ConnectionListener.cs:26,
|
||
/// ClientPacket.cs:14), so the widest list a C2S datagram can carry is
|
||
/// (1024 − 20 header − 4 count) / 4 = 250 ids.</item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </summary>
|
||
internal sealed class AceSessionModel
|
||
{
|
||
// ---- ACE constants, cited ----
|
||
/// <summary>NetworkSession.cs:381 — max NAK ids per RequestRetransmit.</summary>
|
||
private const int MaxNumNakSeqIds = 115;
|
||
/// <summary>ServerPacket.cs:11 — the S2C body budget after the 20-byte header.</summary>
|
||
private const int MaxPacketSize = 464;
|
||
/// <summary>GameMessageGroup.cs:18 — the bundle array length.</summary>
|
||
private const int QueueMax = 0x0C;
|
||
/// <summary>NetworkSession.cs:359 — `new TimeSpan(0, 0, 1)` NAK rate limit.</summary>
|
||
private static readonly long NakRateLimitTicks = TimeSpan.FromSeconds(1).Ticks;
|
||
/// <summary>NetworkSession.cs:32 — timeBetweenAck = 2000 ms.</summary>
|
||
private static readonly long AckIntervalTicks = TimeSpan.FromSeconds(2).Ticks;
|
||
/// <summary>NetworkSession.cs:31 — timeBetweenTimeSync = 20000 ms.</summary>
|
||
private static readonly long TimeSyncIntervalTicks = TimeSpan.FromSeconds(20).Ticks;
|
||
/// <summary>NetworkManager.DefaultSessionTimeout (60 s), applied at NetworkSession.cs:329-331.</summary>
|
||
private static readonly long SessionTimeoutTicks = TimeSpan.FromSeconds(60).Ticks;
|
||
/// <summary>NetworkSession.cs:67 — cachedPacketPruneInterval = 5 s.</summary>
|
||
private static readonly long CachePruneIntervalTicks = TimeSpan.FromSeconds(5).Ticks;
|
||
/// <summary>NetworkSession.cs:72 — cachedPacketRetentionTime = 120 s.</summary>
|
||
private const int CachedPacketRetentionSeconds = 120;
|
||
/// <summary>SessionTerminationDetails.cs:12 — TerminationEndTicks = start + 2 s.</summary>
|
||
private static readonly long TerminationWindowTicks = TimeSpan.FromSeconds(2).Ticks;
|
||
|
||
// ---- identity / handshake material ----
|
||
private readonly VirtualClock _clock;
|
||
private readonly ushort _serverId;
|
||
private readonly uint _clientId;
|
||
private readonly ulong _cookie;
|
||
private readonly uint _clientSeed;
|
||
private readonly uint _serverSeed;
|
||
|
||
/// <summary>C2S verifier — SessionConnectionData.CryptoClient (SessionConnectionData.cs:61).</summary>
|
||
public AceCryptoModel Crypto { get; }
|
||
|
||
/// <summary>S2C keystream — SessionConnectionData.IssacServer (SessionConnectionData.cs:62).</summary>
|
||
private readonly IsaacRandom _s2cKeystream;
|
||
|
||
// ---- receive state ----
|
||
/// <summary>NetworkSession.cs:57 — starts at 1.</summary>
|
||
private uint _lastReceivedPacketSequence = 1;
|
||
/// <summary>NetworkSession.cs:58 — starts at 0.</summary>
|
||
private uint _lastReceivedFragmentSequence;
|
||
/// <summary>NetworkSession.cs:41 — outOfOrderPackets (parsed + CRC-verified; never re-verified).</summary>
|
||
private readonly Dictionary<uint, ParsedPacket> _outOfOrderPackets = new();
|
||
/// <summary>NetworkSession.cs:42 — partialFragments (multi-fragment C2S reassembly).</summary>
|
||
private readonly Dictionary<uint, PartialC2SMessage> _partialFragments = new();
|
||
/// <summary>NetworkSession.cs:43 — outOfOrderFragments (the C2S fragment gate buffer).</summary>
|
||
private readonly Dictionary<uint, byte[]> _outOfOrderFragments = new();
|
||
/// <summary>NetworkSession.cs:428 — LastRequestForRetransmitTime (DateTime.MinValue ≙ null).</summary>
|
||
private long? _lastNakTimestamp;
|
||
|
||
// ---- send state ----
|
||
/// <summary>
|
||
/// ACE's ConnectionData.PacketSequence is UIntSequence(clientPrimed:false)
|
||
/// (SessionConnectionData.cs:66): CurrentValue starts at uint.MaxValue and
|
||
/// the first NextValue wraps to 0 (UIntSequence.cs:19-41), so the
|
||
/// cleartext ConnectRequest goes out with sequence 0. The first ENCRYPTED
|
||
/// flush re-primes CurrentValue to 1 (NetworkSession.cs:716-717), making
|
||
/// the first encrypted S2C packet sequence 2.
|
||
/// </summary>
|
||
private uint _packetSequence = uint.MaxValue;
|
||
/// <summary>SessionConnectionData.cs:36 — FragmentSequence, default 0; assigned at bundle flush (NetworkSession.cs:821).</summary>
|
||
private uint _s2cFragmentSequence;
|
||
/// <summary>NetworkSession.cs:65 — cachedPackets, keyed by sequence.</summary>
|
||
private readonly Dictionary<uint, CachedS2CPacket> _cachedPackets = new();
|
||
private long? _lastPruneTimestamp;
|
||
private long _nextAckTimestamp;
|
||
private long? _nextResyncTimestamp;
|
||
private bool _sendResync;
|
||
/// <summary>NetworkSession.cs:38-39 — one NetworkBundle per GameMessageGroup.</summary>
|
||
private readonly PendingBundle[] _bundles = new PendingBundle[QueueMax];
|
||
/// <summary>NetworkSession.cs:81 — packetQueue, drained by FlushPackets in Update.</summary>
|
||
private readonly Queue<OutboundDraft> _flushQueue = new();
|
||
|
||
// ---- termination state ----
|
||
private long _terminationEndTimestamp;
|
||
|
||
// ---- observable outputs ----
|
||
private readonly List<byte[]> _dispatchedMessages = new();
|
||
private readonly List<byte[]> _sentDatagrams = new();
|
||
private readonly Queue<byte[]> _pendingOutbound = new();
|
||
|
||
public AceSessionModel(
|
||
VirtualClock clock,
|
||
uint clientSeed,
|
||
uint serverSeed,
|
||
uint clientId,
|
||
ulong cookie,
|
||
ushort serverId = 0x000C)
|
||
{
|
||
_clock = clock;
|
||
_clientSeed = clientSeed;
|
||
_serverSeed = serverSeed;
|
||
_clientId = clientId;
|
||
_cookie = cookie;
|
||
_serverId = serverId;
|
||
|
||
Crypto = new AceCryptoModel(clientSeed);
|
||
Span<byte> seedBytes = stackalloc byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, serverSeed);
|
||
_s2cKeystream = new IsaacRandom(seedBytes);
|
||
|
||
for (int i = 0; i < _bundles.Length; i++)
|
||
_bundles[i] = new PendingBundle(); // NetworkSession.cs:105-109
|
||
|
||
// NetworkSession.cs:54-55 — sendAck starts true with the 2 s delay armed.
|
||
_nextAckTimestamp = clock.GetTimestamp() + AckIntervalTicks;
|
||
// Simplified from NetworkSession.cs:102-103 (15 s pre-auth window);
|
||
// the double pins the 60 s in-world horizon of :329-331 only.
|
||
TimeoutDeadlineTimestamp = clock.GetTimestamp() + SessionTimeoutTicks;
|
||
}
|
||
|
||
// ---- diagnostics for assertions ----
|
||
/// <summary>Session.State (Session.cs:36) — the value CheckState reads.</summary>
|
||
public AceSessionState State { get; private set; } = AceSessionState.AuthLoginRequest;
|
||
public uint LastReceivedPacketSequence => _lastReceivedPacketSequence;
|
||
public uint LastReceivedFragmentSequence => _lastReceivedFragmentSequence;
|
||
/// <summary>Fully-assembled C2S message bodies in ACE dispatch order.</summary>
|
||
public IReadOnlyList<byte[]> DispatchedMessages => _dispatchedMessages;
|
||
/// <summary>Every S2C datagram the model has emitted, in send order (cumulative).</summary>
|
||
public IReadOnlyList<byte[]> SentDatagrams => _sentDatagrams;
|
||
/// <summary>
|
||
/// True once <c>Session.Terminate</c> has armed PendingTermination
|
||
/// (Session.cs:281-298). Inbound and outbound keep running for the ~2 s
|
||
/// termination window — see <see cref="IsReleased"/> for the point of no
|
||
/// return.
|
||
/// </summary>
|
||
public bool IsTerminated => TerminationPhase is not null;
|
||
/// <summary>Session.PendingTermination.TerminationStatus (Session.cs:56, :126-131).</summary>
|
||
public AceTerminationPhase? TerminationPhase { get; private set; }
|
||
/// <summary>
|
||
/// NetworkSession.isReleased (:952, :958-974) — set by Session.DropSession
|
||
/// (:300-334) once the termination window closed. From here every inbound
|
||
/// packet and every pump is ignored.
|
||
/// </summary>
|
||
public bool IsReleased { get; private set; }
|
||
public AceTerminationReason TerminationReason { get; private set; } = AceTerminationReason.None;
|
||
/// <summary>VirtualClock timestamp at or past which <see cref="Update"/> terminates the session.</summary>
|
||
public long TimeoutDeadlineTimestamp { get; private set; }
|
||
public int OutOfOrderPacketCount => _outOfOrderPackets.Count;
|
||
/// <summary>Completed messages parked behind the C2S fragment gate (NetworkSession.cs:539-542).</summary>
|
||
public int FragmentGateBufferCount => _outOfOrderFragments.Count;
|
||
public int PartialFragmentBufferCount => _partialFragments.Count;
|
||
public int CachedPacketCount => _cachedPackets.Count;
|
||
public IReadOnlyCollection<uint> CachedPacketSequences => _cachedPackets.Keys;
|
||
/// <summary>Packets silently dropped by CRC/Search failure (NetworkSession.cs:277-280).</summary>
|
||
public int CrcDropCount { get; private set; }
|
||
/// <summary>Packets dropped by the duplicate-rejection rule (NetworkSession.cs:342-347).</summary>
|
||
public int DuplicateDropCount { get; private set; }
|
||
/// <summary>Packets dropped by the Session.CheckState gate (Session.cs:93-110) — before CRC.</summary>
|
||
public int StateDropCount { get; private set; }
|
||
public int RetransmitsServed { get; private set; }
|
||
|
||
// ---- script hooks for FakeAceTransport ----
|
||
/// <summary>Fired when a LoginRequest packet is handled (NetworkSession.cs:463-468).</summary>
|
||
public event Action? LoginRequestReceived;
|
||
/// <summary>Fired when a cookie-matching ConnectResponse is accepted (NetworkManager.cs:50-79).</summary>
|
||
public event Action? ConnectResponseAccepted;
|
||
/// <summary>Fired per dispatched C2S message body, in ACE dispatch order.</summary>
|
||
public event Action<byte[]>? MessageDispatched;
|
||
|
||
/// <summary>Drain the datagrams emitted since the last call, in send order.</summary>
|
||
public List<byte[]> TakePendingDatagrams()
|
||
{
|
||
var drained = new List<byte[]>(_pendingOutbound.Count);
|
||
while (_pendingOutbound.TryDequeue(out byte[]? datagram))
|
||
drained.Add(datagram);
|
||
return drained;
|
||
}
|
||
|
||
// =====================================================================
|
||
// Receive pipeline — Session.ProcessPacket (Session.cs:107-113) then
|
||
// NetworkSession.ProcessPacket (:269-379), in ACE's exact order.
|
||
// =====================================================================
|
||
public void Receive(ReadOnlySpan<byte> datagram)
|
||
{
|
||
if (IsReleased)
|
||
return; // isReleased guard (:271-272)
|
||
|
||
if (!TryParse(datagram, out ParsedPacket packet))
|
||
return; // ClientPacket.Unpack failure — ConnectionListener discards silently
|
||
|
||
// 0. Session.CheckState (Session.cs:93-110) runs BEFORE
|
||
// NetworkSession.ProcessPacket — so before VerifyCRC. A packet
|
||
// dropped here costs ZERO keystream: ACE never looked at its
|
||
// checksum.
|
||
if (!CheckState(packet.Header))
|
||
{
|
||
StateDropCount++;
|
||
return;
|
||
}
|
||
|
||
// ConnectResponse is routed by flag BEFORE the session pipeline
|
||
// (NetworkManager.cs:50-79): its CRC is never verified (VerifyCRC only
|
||
// runs inside NetworkSession.ProcessPacket) and no dedup/watermark
|
||
// applies — the 64-bit cookie is the authenticator
|
||
// (PacketInboundConnectResponse). The state requirement is identical
|
||
// on both paths (NetworkManager.cs:64 vs Session.cs:98-99), so running
|
||
// CheckState first is faithful for the double's single-listener shape.
|
||
if ((packet.Header.Flags & PacketHeaderFlags.ConnectResponse) != 0)
|
||
{
|
||
HandleConnectResponse(packet);
|
||
return;
|
||
}
|
||
|
||
// 1. CRC verification (:277-280). Failure → silent drop; note the
|
||
// timeout refresh below is NOT reached, so a CRC-failing flood
|
||
// cannot keep a session alive.
|
||
if (!VerifyCrc(packet))
|
||
{
|
||
CrcDropCount++;
|
||
return;
|
||
}
|
||
|
||
// 2. Cleartext-NAK early handling (:283-308): RequestRetransmit set
|
||
// AND EncryptedChecksum NOT set → serve retransmits (immediate raw
|
||
// sends), queue RejectRetransmit for uncached ids, and RETURN —
|
||
// before the timeout refresh, so NAKs never refresh ACE's 60 s
|
||
// timeout. Encrypted NAKs fall through and are effectively
|
||
// ignored (:283-284 requires the cleartext form).
|
||
if ((packet.Header.Flags & PacketHeaderFlags.RequestRetransmit) != 0
|
||
&& (packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) == 0)
|
||
{
|
||
List<uint>? uncached = null;
|
||
foreach (uint sequence in packet.Optional.RetransmitRequests)
|
||
{
|
||
if (!TryRetransmit(sequence))
|
||
(uncached ??= new List<uint>()).Add(sequence);
|
||
}
|
||
|
||
if (uncached is not null)
|
||
EnqueueRejectRetransmit(uncached); // :299-304 (sent on the next Update flush)
|
||
return; // :307
|
||
}
|
||
|
||
// 3. Disconnect headers (:312-321).
|
||
if ((packet.Header.Flags & PacketHeaderFlags.Disconnect) != 0)
|
||
{
|
||
Terminate(AceTerminationReason.PacketHeaderDisconnect);
|
||
return;
|
||
}
|
||
|
||
if ((packet.Header.Flags & PacketHeaderFlags.NetErrorDisconnect) != 0)
|
||
{
|
||
Terminate(AceTerminationReason.ClientSentNetworkErrorDisconnect);
|
||
return;
|
||
}
|
||
|
||
// 4. Timeout refresh (:329-331) — 60 s in-world horizon.
|
||
TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks;
|
||
|
||
// 5. Duplicate rejection (:342-347). Raw unsigned comparison — NOT
|
||
// wrap-safe, exactly like ACE (a wrapped client sequence would be
|
||
// mis-classified; modeled bug-for-bug). The ack-only exemption is
|
||
// an EQUALITY check on the whole flags field, never HasFlag, and
|
||
// only at seq == watermark exactly.
|
||
if (packet.Header.Sequence <= _lastReceivedPacketSequence
|
||
&& packet.Header.Sequence != 0
|
||
&& !(packet.Header.Flags == PacketHeaderFlags.AckSequence
|
||
&& packet.Header.Sequence == _lastReceivedPacketSequence))
|
||
{
|
||
DuplicateDropCount++;
|
||
return;
|
||
}
|
||
|
||
// 6. Out-of-order buffering (:351-363). NAK trigger fires only at
|
||
// desiredSeq + 2 ≤ arrivedSeq, arrival-driven, with a 1 s rate
|
||
// limit; a quiet link is never NAKed.
|
||
uint desiredSeq = _lastReceivedPacketSequence + 1;
|
||
if (packet.Header.Sequence > desiredSeq)
|
||
{
|
||
if (!_outOfOrderPackets.ContainsKey(packet.Header.Sequence))
|
||
_outOfOrderPackets.Add(packet.Header.Sequence, packet);
|
||
|
||
bool rateLimitOpen =
|
||
_lastNakTimestamp is null
|
||
|| _clock.GetTimestamp() - _lastNakTimestamp.Value > NakRateLimitTicks;
|
||
if (desiredSeq + 2 <= packet.Header.Sequence && rateLimitOpen)
|
||
DoRequestForRetransmission(packet.Header.Sequence);
|
||
return;
|
||
}
|
||
|
||
// 7. Final processing (:367-378).
|
||
HandleOrderedPacket(packet);
|
||
CheckOutOfOrderPackets();
|
||
CheckOutOfOrderFragments();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Session.CheckState (Session.cs:93-105). Three drops, all of them
|
||
/// BEFORE <c>NetworkSession.ProcessPacket</c> and therefore before
|
||
/// <c>ClientPacket.VerifyCRC</c> — no keystream word is consumed, no
|
||
/// watermark moves, no CRC counter ticks. Note ACE's
|
||
/// <c>PacketHeader.HasFlag</c> is ANY-of, not all-of
|
||
/// (PacketHeader.cs:70), so the fourth line drops a packet carrying ANY
|
||
/// of the four control flags.
|
||
/// </summary>
|
||
private bool CheckState(PacketHeader header)
|
||
{
|
||
// :95-96
|
||
if ((header.Flags & PacketHeaderFlags.LoginRequest) != 0
|
||
&& State != AceSessionState.AuthLoginRequest)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// :98-99 (and the identical requirement on NetworkManager's port+1
|
||
// path, NetworkManager.cs:60-66).
|
||
if ((header.Flags & PacketHeaderFlags.ConnectResponse) != 0
|
||
&& State != AceSessionState.AuthConnectResponse)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// :101-102
|
||
const PacketHeaderFlags controlFlags =
|
||
PacketHeaderFlags.AckSequence
|
||
| PacketHeaderFlags.TimeSync
|
||
| PacketHeaderFlags.EchoRequest
|
||
| PacketHeaderFlags.Flow;
|
||
if ((header.Flags & controlFlags) != 0
|
||
&& State == AceSessionState.AuthLoginRequest)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>ClientPacket.VerifyCRC (ClientPacket.cs:138-163) over the crypto model.</summary>
|
||
private bool VerifyCrc(ParsedPacket packet)
|
||
{
|
||
uint headerHash = packet.Header.CalculateHeaderHash32();
|
||
uint payloadHash = packet.Optional.CalculateHash32() + packet.FragmentHash;
|
||
|
||
if ((packet.Header.Flags & PacketHeaderFlags.EncryptedChecksum) != 0)
|
||
{
|
||
// ClientPacket.cs:140-147 — extract the key, Search, then Consume.
|
||
uint key = (packet.Header.Checksum - headerHash) ^ payloadHash;
|
||
if (Crypto.Search(key))
|
||
{
|
||
Crypto.ConsumeKey(key);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// ClientPacket.cs:149-157 — additive cleartext checksum.
|
||
return headerHash + payloadHash == packet.Header.Checksum;
|
||
}
|
||
|
||
/// <summary>
|
||
/// NetworkManager.cs:50-79 — ConnectResponse routing. The double only
|
||
/// supports the exact shape retail/acdream sends (flags ==
|
||
/// ConnectResponse alone, 8-byte cookie body). The
|
||
/// <c>State == AuthConnectResponse</c> half of NetworkManager's session
|
||
/// lookup (:64) is enforced by <see cref="CheckState"/> above.
|
||
/// </summary>
|
||
private void HandleConnectResponse(ParsedPacket packet)
|
||
{
|
||
if (packet.Header.Flags != PacketHeaderFlags.ConnectResponse)
|
||
return;
|
||
if (packet.Optional.RawBytes.Length < 8)
|
||
return;
|
||
ulong cookie = BinaryPrimitives.ReadUInt64LittleEndian(packet.Optional.RawBytes);
|
||
if (cookie != _cookie)
|
||
return; // NetworkManager.cs:60-66 — cookie mismatch: no session matches, ignored
|
||
|
||
State = AceSessionState.AuthConnected; // NetworkManager.cs:77
|
||
_sendResync = true; // NetworkManager.cs:78 — first TimeSync goes out immediately (:47-50)
|
||
TimeoutDeadlineTimestamp = _clock.GetTimestamp() + SessionTimeoutTicks;
|
||
ConnectResponseAccepted?.Invoke();
|
||
}
|
||
|
||
/// <summary>NetworkSession.HandleOrderedPacket (:435-477).</summary>
|
||
private void HandleOrderedPacket(ParsedPacket packet)
|
||
{
|
||
// :440-443 + :650-661 — EchoRequest flags an EchoResponse onto the
|
||
// InvalidQueue bundle (and forces its checksum encrypted).
|
||
if ((packet.Header.Flags & PacketHeaderFlags.EchoRequest) != 0)
|
||
FlagEcho(packet.Optional.EchoRequestClientTime);
|
||
|
||
// :447-448 — consume the cumulative-ack VALUE: prune the S2C cache
|
||
// strictly below it.
|
||
if ((packet.Header.Flags & PacketHeaderFlags.AckSequence) != 0)
|
||
AcknowledgeSequence(packet.Optional.AckSequence);
|
||
|
||
// :450-457 — inbound TimeSync is read and ignored.
|
||
|
||
// :463-468 — LoginRequest short-circuits to the auth handler and
|
||
// RETURNS: no fragment processing and, crucially, no watermark
|
||
// advance for LoginRequest packets.
|
||
if ((packet.Header.Flags & PacketHeaderFlags.LoginRequest) != 0)
|
||
{
|
||
LoginRequestReceived?.Invoke();
|
||
return;
|
||
}
|
||
|
||
// :471-472 — fragments.
|
||
foreach (MessageFragment fragment in packet.Fragments)
|
||
ProcessFragment(fragment);
|
||
|
||
// :474-476 — THE WATERMARK-HOLE RULE, pinned: the watermark advances
|
||
// for every packet whose Sequence != 0 && Flags != AckSequence — an
|
||
// EXACT equality check on the whole flags field. Any cleartext
|
||
// non-ack control packet reusing a live sequence number advances the
|
||
// watermark and permanently skips the real packet at that sequence.
|
||
if (packet.Header.Sequence != 0
|
||
&& packet.Header.Flags != PacketHeaderFlags.AckSequence)
|
||
{
|
||
_lastReceivedPacketSequence = packet.Header.Sequence;
|
||
}
|
||
}
|
||
|
||
/// <summary>NetworkSession.FlagEcho (:650-661).</summary>
|
||
private void FlagEcho(float clientTime)
|
||
{
|
||
PendingBundle bundle = _bundles[(int)GameMessageGroup.InvalidQueue];
|
||
bundle.ClientTime = clientTime;
|
||
bundle.EncryptedChecksum = true;
|
||
}
|
||
|
||
/// <summary>NetworkSession.ProcessFragment (:483-544).</summary>
|
||
private void ProcessFragment(MessageFragment fragment)
|
||
{
|
||
byte[]? message = null;
|
||
|
||
if (fragment.Header.Count != 1)
|
||
{
|
||
// ACE's two literal branches, kept separate because only ONE of
|
||
// them checks Complete:
|
||
if (_partialFragments.TryGetValue(fragment.Header.Sequence, out PartialC2SMessage? buffer))
|
||
{
|
||
// :495-507 — existing buffer: add, then check Complete.
|
||
buffer.AddFragment(fragment.Header.Index, fragment.Payload);
|
||
if (buffer.Complete)
|
||
{
|
||
// :504-506 — TryGetMessage may return null (assembled
|
||
// stream under 4 bytes, MessageBuffer.cs:49-50) but the
|
||
// buffer is removed EITHER WAY.
|
||
message = buffer.TryGetMessage();
|
||
_partialFragments.Remove(fragment.Header.Sequence);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// :509-518 — new buffer: construct + AddFragment + TryAdd,
|
||
// WITHOUT checking Complete. A fragment whose Count can
|
||
// never be reached from here (Count == 0: AddFragment
|
||
// refuses to add to an already-"Complete" buffer) parks a
|
||
// dead buffer in partialFragments forever — ACE
|
||
// bug-for-bug.
|
||
var newBuffer = new PartialC2SMessage(fragment.Header.Count);
|
||
newBuffer.AddFragment(fragment.Header.Index, fragment.Payload);
|
||
_partialFragments.TryAdd(fragment.Header.Sequence, newBuffer);
|
||
}
|
||
}
|
||
else if (fragment.Payload.Length >= 4)
|
||
{
|
||
// :520-527 — unsplit; ClientMessage needs ≥ 4 bytes.
|
||
message = fragment.Payload;
|
||
}
|
||
|
||
if (message is null)
|
||
return;
|
||
|
||
// :532-543 — THE C2S FRAGMENT GATE, pinned: a completed message
|
||
// dispatches only when its fragment sequence is exactly
|
||
// lastReceivedFragmentSequence + 1; anything else parks in
|
||
// outOfOrderFragments (including OLD fragment sequences, which park
|
||
// forever — ACE bug-for-bug). A message dropped for being under
|
||
// 4 bytes never reaches here, so it never advances the gate: every
|
||
// later message stalls behind the hole it leaves.
|
||
if (fragment.Header.Sequence == _lastReceivedFragmentSequence + 1)
|
||
HandleFragment(message);
|
||
else
|
||
_outOfOrderFragments.TryAdd(fragment.Header.Sequence, message);
|
||
}
|
||
|
||
/// <summary>NetworkSession.HandleFragment (:550-554).</summary>
|
||
private void HandleFragment(byte[] message)
|
||
{
|
||
_dispatchedMessages.Add(message);
|
||
MessageDispatched?.Invoke(message);
|
||
_lastReceivedFragmentSequence++;
|
||
}
|
||
|
||
/// <summary>NetworkSession.CheckOutOfOrderPackets (:559-566).</summary>
|
||
private void CheckOutOfOrderPackets()
|
||
{
|
||
while (_outOfOrderPackets.Remove(_lastReceivedPacketSequence + 1, out ParsedPacket? packet))
|
||
HandleOrderedPacket(packet);
|
||
}
|
||
|
||
/// <summary>NetworkSession.CheckOutOfOrderFragments (:571-578).</summary>
|
||
private void CheckOutOfOrderFragments()
|
||
{
|
||
while (_outOfOrderFragments.Remove(_lastReceivedFragmentSequence + 1, out byte[]? message))
|
||
HandleFragment(message);
|
||
}
|
||
|
||
/// <summary>NetworkSession.AcknowledgeSequence (:663-673) — prune strictly-older
|
||
/// cached S2C packets. Raw uint compare (`x < sequence`), NOT wrap-safe:
|
||
/// modeled exactly as ACE does it.</summary>
|
||
private void AcknowledgeSequence(uint sequence)
|
||
{
|
||
List<uint>? removal = null;
|
||
foreach (uint key in _cachedPackets.Keys)
|
||
{
|
||
if (key < sequence)
|
||
(removal ??= new List<uint>()).Add(key);
|
||
}
|
||
|
||
if (removal is null)
|
||
return;
|
||
foreach (uint key in removal)
|
||
_cachedPackets.Remove(key);
|
||
}
|
||
|
||
/// <summary>NetworkSession.DoRequestForRetransmission (:387-426).</summary>
|
||
private void DoRequestForRetransmission(uint rcvdSeq)
|
||
{
|
||
uint desiredSeq = _lastReceivedPacketSequence + 1; // :389
|
||
var needSeq = new List<uint> { desiredSeq }; // :390-391
|
||
uint bottom = desiredSeq + 1; // :392
|
||
// :393-397 — gap beyond the 256-key crypto search window is fatal.
|
||
// Note this check lives INSIDE the rate-limited call, exactly like
|
||
// ACE: a huge gap arriving while the 1 s limiter is closed does NOT
|
||
// terminate until the next NAK-eligible arrival.
|
||
if (rcvdSeq < bottom || rcvdSeq - bottom > AceCryptoModel.MaximumEffortLevel)
|
||
{
|
||
Terminate(AceTerminationReason.AbnormalSequenceReceived);
|
||
return;
|
||
}
|
||
|
||
uint seqIdCount = 1; // :398-410 — cap at 115 ids, skipping buffered arrivals
|
||
for (uint a = bottom; a < rcvdSeq; a++)
|
||
{
|
||
if (_outOfOrderPackets.ContainsKey(a))
|
||
continue;
|
||
needSeq.Add(a);
|
||
seqIdCount++;
|
||
if (seqIdCount >= MaxNumNakSeqIds)
|
||
break;
|
||
}
|
||
|
||
// :412-420 — u32 count + ids, flags RequestRetransmit, CLEARTEXT
|
||
// (ServerPacket default — no EncryptedChecksum), queued for the next
|
||
// FlushPackets pass.
|
||
byte[] body = new byte[4 + needSeq.Count * 4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)needSeq.Count);
|
||
for (int i = 0; i < needSeq.Count; i++)
|
||
{
|
||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||
body.AsSpan(4 + i * 4),
|
||
needSeq[i]);
|
||
}
|
||
|
||
_flushQueue.Enqueue(new OutboundDraft(
|
||
PacketHeaderFlags.RequestRetransmit,
|
||
body,
|
||
OptionalLength: body.Length));
|
||
|
||
_lastNakTimestamp = _clock.GetTimestamp(); // :422
|
||
}
|
||
|
||
/// <summary>NetworkSession.Retransmit (:675-708) — serve a NAKed id from the cache.</summary>
|
||
private bool TryRetransmit(uint sequence)
|
||
{
|
||
if (!_cachedPackets.TryGetValue(sequence, out CachedS2CPacket? cached))
|
||
return false; // :707 — caller collects the id for RejectRetransmit
|
||
|
||
// :681-682 — OR the Retransmission flag INTO THE CACHE ENTRY (it
|
||
// sticks for any later retransmit of the same packet).
|
||
cached.Flags |= PacketHeaderFlags.Retransmission;
|
||
|
||
// :684 SendPacketRaw → ServerPacket.CreateReadyToSendPacket
|
||
// (ServerPacket.cs:46-72): the header hash is recomputed with the new
|
||
// flags, the checksum reuses the ORIGINAL IssacXor — NO new keystream
|
||
// word is drawn — and Header.Time keeps its original flush value.
|
||
// The retransmit bypasses FlushPackets: it is emitted immediately,
|
||
// before any queued RejectRetransmit.
|
||
Emit(cached.Sequence, cached.Flags, cached.Time, cached.Body, cached.OptionalLength, cached.IsaacXor);
|
||
RetransmitsServed++;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>NetworkSession.cs:299-304 + PacketRejectRetransmit.cs:7-17 —
|
||
/// u32 count + uncached ids, cleartext, queued (flows through FlushPackets,
|
||
/// so like ACE it consumes a sequence number and can even be cached).</summary>
|
||
private void EnqueueRejectRetransmit(List<uint> uncached)
|
||
{
|
||
byte[] body = new byte[4 + uncached.Count * 4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)uncached.Count);
|
||
for (int i = 0; i < uncached.Count; i++)
|
||
{
|
||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||
body.AsSpan(4 + i * 4),
|
||
uncached[i]);
|
||
}
|
||
|
||
_flushQueue.Enqueue(new OutboundDraft(
|
||
PacketHeaderFlags.RejectRetransmit,
|
||
body,
|
||
OptionalLength: body.Length));
|
||
}
|
||
|
||
// =====================================================================
|
||
// Send side — Session.TickOutbound (Session.cs:119-176) →
|
||
// NetworkSession.Update (:182-249) + SendBundle (:808-919) +
|
||
// FlushPackets (:710-735) + SendPacket (:737-752), driven by the virtual
|
||
// clock.
|
||
// =====================================================================
|
||
|
||
/// <summary>
|
||
/// One server pump: the termination window, the timeout check, then the
|
||
/// network update (cache prune, bundles, flush). ACE runs this from the
|
||
/// world tick; the double runs it whenever the harness pumps.
|
||
/// </summary>
|
||
public void Update()
|
||
{
|
||
if (IsReleased)
|
||
return; // NetworkSession.cs:184-185
|
||
|
||
// Session.cs:124-134 — the two-phase termination window. Everything
|
||
// keeps running for ~2 s (SessionTerminationDetails.cs:12) so boot
|
||
// messages and queued packets can still reach the client.
|
||
if (TerminationPhase is not null)
|
||
{
|
||
if (TerminationPhase == AceTerminationPhase.Initialized)
|
||
{
|
||
RunNetworkUpdate(); // :129 — "boot messages may need sending"
|
||
if (_clock.GetTimestamp() > _terminationEndTimestamp)
|
||
TerminationPhase = AceTerminationPhase.SessionWorkCompleted; // :130-131
|
||
}
|
||
|
||
// NetworkManager.cs:366-369 drains completed sessions through
|
||
// Session.DropSession (:300-334), whose last act is
|
||
// NetworkSession.ReleaseResources (:333 → :958-974). The double
|
||
// folds that drain into the same pump.
|
||
if (TerminationPhase == AceTerminationPhase.SessionWorkCompleted)
|
||
Release();
|
||
|
||
return; // :133
|
||
}
|
||
|
||
// Session.cs:140-144 — `DateTime.UtcNow.Ticks >= Network.TimeoutTick`.
|
||
// The boundary is inclusive: at exactly the deadline the session dies.
|
||
// Every ACE transport death is silence — no disconnect packet is sent.
|
||
if (_clock.GetTimestamp() >= TimeoutDeadlineTimestamp)
|
||
{
|
||
Terminate(AceTerminationReason.NetworkTimeout);
|
||
return;
|
||
}
|
||
|
||
RunNetworkUpdate(); // :147
|
||
}
|
||
|
||
/// <summary>NetworkSession.Update (:182-249).</summary>
|
||
private void RunNetworkUpdate()
|
||
{
|
||
// :187-188 — prune the S2C cache every 5 s.
|
||
if (_lastPruneTimestamp is null
|
||
|| _clock.GetTimestamp() - _lastPruneTimestamp.Value > CachePruneIntervalTicks)
|
||
{
|
||
PruneCachedPackets();
|
||
}
|
||
|
||
if (State == AceSessionState.AuthConnected)
|
||
{
|
||
// :190-246 — walk every bundle in ascending group order, arm the
|
||
// InvalidQueue control flags, swap out and send whatever needs
|
||
// sending.
|
||
for (int i = 0; i < QueueMax; i++)
|
||
{
|
||
var group = (GameMessageGroup)i;
|
||
PendingBundle bundle = _bundles[i];
|
||
|
||
if (group == GameMessageGroup.InvalidQueue)
|
||
{
|
||
// :203-209 — TimeSync forces an encrypted checksum.
|
||
if (_sendResync
|
||
&& !bundle.TimeSync
|
||
&& (_nextResyncTimestamp is null
|
||
|| _clock.GetTimestamp() > _nextResyncTimestamp.Value))
|
||
{
|
||
bundle.TimeSync = true;
|
||
bundle.EncryptedChecksum = true;
|
||
_nextResyncTimestamp = _clock.GetTimestamp() + TimeSyncIntervalTicks;
|
||
}
|
||
|
||
// :211-216 — sendAck is always true (:54); a pure ack stays
|
||
// CLEARTEXT.
|
||
if (!bundle.SendAck && _clock.GetTimestamp() > _nextAckTimestamp)
|
||
{
|
||
bundle.SendAck = true;
|
||
_nextAckTimestamp = _clock.GetTimestamp() + AckIntervalTicks;
|
||
}
|
||
}
|
||
|
||
if (!bundle.NeedsSending)
|
||
continue;
|
||
|
||
_bundles[i] = new PendingBundle(); // :223 / :233 — swap
|
||
SendBundle(bundle, group); // :243
|
||
}
|
||
}
|
||
|
||
// FlushPackets (:710-735) — drains receive-time NAK/Reject enqueues
|
||
// first (FIFO), then this pump's bundles.
|
||
while (_flushQueue.TryDequeue(out OutboundDraft draft))
|
||
FlushOne(draft);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Server-side game-message send — NetworkSession.EnqueueSend (:117-134):
|
||
/// the message joins its group's bundle and forces that bundle's checksum
|
||
/// encrypted (:129). It leaves on the next <see cref="Update"/>, coalesced
|
||
/// with whatever else is in the same bundle.
|
||
/// </summary>
|
||
public void EnqueueGameMessage(byte[] gameMessageBody, GameMessageGroup group)
|
||
{
|
||
PendingBundle bundle = _bundles[(int)group];
|
||
bundle.EncryptedChecksum = true;
|
||
bundle.Enqueue(gameMessageBody);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AuthenticationHandler → PacketOutboundConnectRequest
|
||
/// (AuthenticationHandler.cs:118-127): 32-byte cleartext section
|
||
/// (serverTime, cookie, clientId, serverSeed, clientSeed, padding), queued
|
||
/// through the normal packet flush — its sequence is 0, the first
|
||
/// NextValue of the unprimed UIntSequence. The same callback moves the
|
||
/// session to AuthConnectResponse (AuthenticationHandler.cs:232), which is
|
||
/// what closes the LoginRequest half of <see cref="CheckState"/>.
|
||
/// </summary>
|
||
public void SendConnectRequest()
|
||
{
|
||
byte[] body = new byte[32];
|
||
BinaryPrimitives.WriteInt64LittleEndian(
|
||
body,
|
||
BitConverter.DoubleToInt64Bits(_clock.Seconds));
|
||
BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), _cookie);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), _clientId);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), _serverSeed);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), _clientSeed);
|
||
// bytes 28..31: trailing padding uint, zero.
|
||
|
||
_flushQueue.Enqueue(new OutboundDraft(
|
||
PacketHeaderFlags.ConnectRequest,
|
||
body,
|
||
OptionalLength: body.Length));
|
||
|
||
State = AceSessionState.AuthConnectResponse; // AuthenticationHandler.cs:232
|
||
}
|
||
|
||
/// <summary>
|
||
/// NetworkSession.SendBundle (:808-919) — turn one bundle into one OR MORE
|
||
/// packets: as many same-bundle fragments as fit in the 464-byte body
|
||
/// budget travel together (one sequence, one keystream word), and a
|
||
/// message whose remaining data fills a packet is split across packets
|
||
/// with <c>Count > 1</c> fragments.
|
||
///
|
||
/// <para>
|
||
/// Fragment sequences are assigned HERE (:821), from
|
||
/// SessionConnectionData.FragmentSequence (starting at 0), in bundle
|
||
/// order; the fragment Id is the constant 0x80000000 (MessageFragment.cs:94).
|
||
/// </para>
|
||
/// </summary>
|
||
private void SendBundle(PendingBundle bundle, GameMessageGroup group)
|
||
{
|
||
bool writeOptionalHeaders = true;
|
||
|
||
// :817-823 — pull every message out and wrap it in a MessageFragment.
|
||
var fragments = new List<OutboundMessage>();
|
||
while (bundle.HasMoreMessages)
|
||
fragments.Add(new OutboundMessage(bundle.Dequeue(), _s2cFragmentSequence++, group));
|
||
|
||
// :828 — loop while we have fragments (or still owe optional headers).
|
||
while (fragments.Count > 0 || writeOptionalHeaders)
|
||
{
|
||
var flags = PacketHeaderFlags.None;
|
||
var packetFragments = new List<MessageFragment>();
|
||
byte[] optionalBytes = Array.Empty<byte>();
|
||
|
||
if (fragments.Count > 0)
|
||
flags |= PacketHeaderFlags.BlobFragments; // :833-834
|
||
if (bundle.EncryptedChecksum)
|
||
flags |= PacketHeaderFlags.EncryptedChecksum; // :836-837
|
||
|
||
int availableSpace = MaxPacketSize; // :839
|
||
|
||
OutboundMessage? firstMessage = fragments.Count > 0 ? fragments[0] : null; // :842
|
||
if (firstMessage is not null)
|
||
{
|
||
if (firstMessage.DataRemaining >= availableSpace)
|
||
{
|
||
// :846-854 — a large message fills the whole packet alone.
|
||
MessageFragment spf = firstMessage.GetNextFragment();
|
||
packetFragments.Add(spf);
|
||
availableSpace -= spf.WireSize;
|
||
if (firstMessage.DataRemaining <= 0)
|
||
fragments.Remove(firstMessage);
|
||
}
|
||
else
|
||
{
|
||
// :856-903 — optional headers first, then pack in as many
|
||
// small messages (and large-message tails) as fit.
|
||
if (writeOptionalHeaders)
|
||
{
|
||
writeOptionalHeaders = false;
|
||
optionalBytes = WriteOptionalHeaders(bundle, ref flags);
|
||
availableSpace -= optionalBytes.Length;
|
||
}
|
||
|
||
var removeList = new List<OutboundMessage>();
|
||
foreach (OutboundMessage fragment in fragments)
|
||
{
|
||
bool fragmentSkipped = false;
|
||
|
||
if (!fragment.TailSent && availableSpace >= fragment.TailSize)
|
||
{
|
||
// :874-880 — the tail of an already-split message.
|
||
MessageFragment spf = fragment.GetTailFragment();
|
||
packetFragments.Add(spf);
|
||
availableSpace -= spf.WireSize;
|
||
}
|
||
else if (availableSpace >= fragment.NextSize)
|
||
{
|
||
// :882-888 — a whole small message.
|
||
MessageFragment spf = fragment.GetNextFragment();
|
||
packetFragments.Add(spf);
|
||
availableSpace -= spf.WireSize;
|
||
}
|
||
else
|
||
{
|
||
fragmentSkipped = true;
|
||
}
|
||
|
||
if (fragment.DataRemaining <= 0)
|
||
removeList.Add(fragment); // :892-894
|
||
|
||
// :896-898 — UIQueue must stay strictly ordered.
|
||
if (fragmentSkipped && group == GameMessageGroup.UIQueue)
|
||
break;
|
||
}
|
||
|
||
fragments.RemoveAll(removeList.Contains); // :902
|
||
}
|
||
}
|
||
else if (writeOptionalHeaders)
|
||
{
|
||
// :906-916 — no messages: a control-only packet.
|
||
writeOptionalHeaders = false;
|
||
optionalBytes = WriteOptionalHeaders(bundle, ref flags);
|
||
}
|
||
|
||
_flushQueue.Enqueue(BuildDraft(flags, optionalBytes, packetFragments)); // :917
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// NetworkSession.WriteOptionalHeaders (:921-948) — ack value, then
|
||
/// TimeSync, then EchoResponse, in that order. The EncryptedChecksum
|
||
/// forcing for TimeSync/EchoResponse lives on the BUNDLE (:207, :659), not
|
||
/// here, so a pure ack stays cleartext.
|
||
/// </summary>
|
||
private byte[] WriteOptionalHeaders(PendingBundle bundle, ref PacketHeaderFlags flags)
|
||
{
|
||
var writer = new PacketWriter(24);
|
||
|
||
if (bundle.SendAck) // :925-931
|
||
{
|
||
flags |= PacketHeaderFlags.AckSequence;
|
||
writer.WriteUInt32(_lastReceivedPacketSequence);
|
||
}
|
||
|
||
if (bundle.TimeSync) // :933-939
|
||
{
|
||
flags |= PacketHeaderFlags.TimeSync;
|
||
Span<byte> value = stackalloc byte[8];
|
||
BinaryPrimitives.WriteInt64LittleEndian(
|
||
value,
|
||
BitConverter.DoubleToInt64Bits(_clock.Seconds));
|
||
writer.WriteBytes(value);
|
||
}
|
||
|
||
if (bundle.ClientTime != -1f) // :941-948
|
||
{
|
||
flags |= PacketHeaderFlags.EchoResponse;
|
||
writer.WriteFloat(bundle.ClientTime);
|
||
writer.WriteFloat((float)_clock.Seconds - bundle.ClientTime);
|
||
}
|
||
|
||
return writer.ToArray();
|
||
}
|
||
|
||
/// <summary>Concatenate the optional section and the packet's fragments into
|
||
/// one flush draft (ServerPacket.Data + ServerPacket.Fragments).</summary>
|
||
private static OutboundDraft BuildDraft(
|
||
PacketHeaderFlags flags,
|
||
byte[] optionalBytes,
|
||
List<MessageFragment> fragments)
|
||
{
|
||
int total = optionalBytes.Length;
|
||
foreach (MessageFragment fragment in fragments)
|
||
total += fragment.WireSize;
|
||
|
||
byte[] body = new byte[total];
|
||
optionalBytes.CopyTo(body.AsSpan());
|
||
int offset = optionalBytes.Length;
|
||
foreach (MessageFragment fragment in fragments)
|
||
{
|
||
fragment.Header.Pack(body.AsSpan(offset));
|
||
fragment.Payload.CopyTo(body.AsSpan(offset + MessageFragmentHeader.Size));
|
||
offset += fragment.WireSize;
|
||
}
|
||
|
||
return new OutboundDraft(flags, body, optionalBytes.Length);
|
||
}
|
||
|
||
/// <summary>FlushPackets, per packet (:710-735) + SendPacket (:737-752).</summary>
|
||
private void FlushOne(OutboundDraft draft)
|
||
{
|
||
bool encrypted = (draft.Flags & PacketHeaderFlags.EncryptedChecksum) != 0;
|
||
|
||
// :716-717 — the first encrypted flush re-primes the sequence to
|
||
// CurrentValue = 1, so the first encrypted S2C packet is sequence 2.
|
||
if (encrypted && _packetSequence == 0)
|
||
_packetSequence = 1;
|
||
|
||
bool isNak = (draft.Flags & PacketHeaderFlags.RequestRetransmit) != 0; // :719
|
||
|
||
// :722-725 — ack-only (EXACT flags) and NAK packets reuse the current
|
||
// sequence without incrementing; everything else takes NextValue.
|
||
uint sequence = draft.Flags == PacketHeaderFlags.AckSequence || isNak
|
||
? _packetSequence
|
||
: NextPacketSequence();
|
||
|
||
// :728 — Header.Time = (ushort)PortalYearTicks (whole seconds).
|
||
ushort time = (ushort)(long)_clock.Seconds;
|
||
|
||
// SendPacket (:743-748) — one S2C keystream word per encrypted
|
||
// packet; cleartext packets use xor 0 (ServerPacket.cs:70 makes the
|
||
// checksum additive in that case).
|
||
uint isaacXor = encrypted ? _s2cKeystream.Next() : 0u;
|
||
|
||
// :730-731 — cache sequenced packets ≥ 2 that are not NAKs. TryAdd
|
||
// semantics: an ack reusing a live sequence does not overwrite.
|
||
if (sequence >= 2u && !isNak)
|
||
{
|
||
_cachedPackets.TryAdd(sequence, new CachedS2CPacket
|
||
{
|
||
Sequence = sequence,
|
||
Flags = draft.Flags,
|
||
Time = time,
|
||
Body = draft.Body,
|
||
OptionalLength = draft.OptionalLength,
|
||
IsaacXor = isaacXor,
|
||
});
|
||
}
|
||
|
||
Emit(sequence, draft.Flags, time, draft.Body, draft.OptionalLength, isaacXor);
|
||
}
|
||
|
||
/// <summary>UIntSequence.NextValue (UIntSequence.cs:30-41): wrap max → 0.</summary>
|
||
private uint NextPacketSequence()
|
||
{
|
||
_packetSequence = _packetSequence == uint.MaxValue ? 0u : _packetSequence + 1u;
|
||
return _packetSequence;
|
||
}
|
||
|
||
/// <summary>ServerPacket.CreateReadyToSendPacket (ServerPacket.cs:46-72).</summary>
|
||
private void Emit(
|
||
uint sequence,
|
||
PacketHeaderFlags flags,
|
||
ushort time,
|
||
byte[] body,
|
||
int optionalLength,
|
||
uint isaacXor)
|
||
{
|
||
var header = new PacketHeader
|
||
{
|
||
Sequence = sequence,
|
||
Flags = flags,
|
||
Id = _serverId, // :726
|
||
Iteration = 1, // :727
|
||
Time = time,
|
||
DataSize = checked((ushort)body.Length),
|
||
};
|
||
|
||
uint payloadHash = ComputePayloadHash(body, flags, optionalLength);
|
||
uint headerHash = header.CalculateHeaderHash32();
|
||
header.Checksum = headerHash + (payloadHash ^ isaacXor); // ServerPacket.cs:70
|
||
|
||
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||
header.Pack(datagram);
|
||
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||
|
||
_sentDatagrams.Add(datagram);
|
||
_pendingOutbound.Enqueue(datagram);
|
||
}
|
||
|
||
/// <summary>ServerPacket.cs:48-62 — Hash32(data section) + Σ fragment hashes.</summary>
|
||
private static uint ComputePayloadHash(
|
||
ReadOnlySpan<byte> body,
|
||
PacketHeaderFlags flags,
|
||
int optionalLength)
|
||
{
|
||
uint hash = Hash32.Calculate(body.Slice(0, optionalLength));
|
||
if ((flags & PacketHeaderFlags.BlobFragments) == 0)
|
||
return hash;
|
||
|
||
ReadOnlySpan<byte> remaining = body.Slice(optionalLength);
|
||
while (!remaining.IsEmpty)
|
||
{
|
||
if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed))
|
||
throw new InvalidOperationException("the model built a malformed fragment");
|
||
hash += PacketCodec.CalculateFragmentHash32(fragment);
|
||
remaining = remaining.Slice(consumed);
|
||
}
|
||
|
||
return hash;
|
||
}
|
||
|
||
/// <summary>NetworkSession.PruneCachedPackets (:251-262) — 120 s retention
|
||
/// with ACE's ushort-wrap guard expression, verbatim. Strictly greater
|
||
/// than 120: an entry exactly 120 s old survives.</summary>
|
||
private void PruneCachedPackets()
|
||
{
|
||
_lastPruneTimestamp = _clock.GetTimestamp(); // :253
|
||
ushort currentTime = (ushort)(long)_clock.Seconds; // :255
|
||
|
||
List<uint>? removal = null;
|
||
foreach (CachedS2CPacket packet in _cachedPackets.Values)
|
||
{
|
||
// :258 — wrap guard: `(currentTime >= x.Time ? currentTime : currentTime + ushort.MaxValue) - x.Time > 120`
|
||
if ((currentTime >= packet.Time ? currentTime : currentTime + ushort.MaxValue) - packet.Time
|
||
> CachedPacketRetentionSeconds)
|
||
{
|
||
(removal ??= new List<uint>()).Add(packet.Sequence);
|
||
}
|
||
}
|
||
|
||
if (removal is null)
|
||
return;
|
||
foreach (uint sequence in removal)
|
||
_cachedPackets.Remove(sequence);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Session.Terminate (Session.cs:281-298) — arms PendingTermination with a
|
||
/// 2 s window (SessionTerminationDetails.cs:11-12). It does NOT stop the
|
||
/// session: inbound keeps processing and outbound keeps flushing until
|
||
/// <see cref="Release"/>. A second Terminate overwrites the details, exactly
|
||
/// like ACE (:293).
|
||
/// </summary>
|
||
private void Terminate(AceTerminationReason reason)
|
||
{
|
||
TerminationReason = reason;
|
||
TerminationPhase = AceTerminationPhase.Initialized;
|
||
_terminationEndTimestamp = _clock.GetTimestamp() + TerminationWindowTicks;
|
||
}
|
||
|
||
/// <summary>NetworkSession.ReleaseResources (:958-974), reached through
|
||
/// Session.DropSession (:300-334).</summary>
|
||
private void Release()
|
||
{
|
||
IsReleased = true;
|
||
_outOfOrderPackets.Clear();
|
||
_partialFragments.Clear();
|
||
_outOfOrderFragments.Clear();
|
||
_cachedPackets.Clear();
|
||
_flushQueue.Clear();
|
||
for (int i = 0; i < _bundles.Length; i++)
|
||
_bundles[i] = new PendingBundle(); // :962-963 (ACE nulls them)
|
||
}
|
||
|
||
// =====================================================================
|
||
// Parsing — ClientPacket.Unpack (ClientPacket.cs:22-76) equivalent over
|
||
// acdream's owned wire types. Malformed datagrams are dropped silently.
|
||
// =====================================================================
|
||
private static bool TryParse(ReadOnlySpan<byte> datagram, out ParsedPacket packet)
|
||
{
|
||
packet = null!;
|
||
if (datagram.Length < PacketHeader.Size)
|
||
return false; // ClientPacket.cs:26-27
|
||
|
||
PacketHeader header = PacketHeader.Unpack(datagram);
|
||
if (header.DataSize > datagram.Length - PacketHeader.Size)
|
||
return false; // ClientPacket.cs:31-32
|
||
|
||
ReadOnlySpan<byte> body = datagram.Slice(PacketHeader.Size, header.DataSize);
|
||
var optional = new PacketHeaderOptional();
|
||
int optionalConsumed = optional.Parse(body, header.Flags);
|
||
if (optionalConsumed < 0)
|
||
return false; // ClientPacket.cs:38-39 (HeaderOptional.IsValid)
|
||
|
||
var fragments = new List<MessageFragment>();
|
||
uint fragmentHash = 0;
|
||
if ((header.Flags & PacketHeaderFlags.BlobFragments) != 0)
|
||
{
|
||
// ClientPacket.ReadFragments (:54-76) + fragmentChecksum (:84-101).
|
||
ReadOnlySpan<byte> remaining = body.Slice(optionalConsumed);
|
||
while (!remaining.IsEmpty)
|
||
{
|
||
if (!TryParseClientFragment(remaining, out MessageFragment fragment, out int consumed))
|
||
return false;
|
||
fragments.Add(fragment);
|
||
fragmentHash += PacketCodec.CalculateFragmentHash32(fragment);
|
||
remaining = remaining.Slice(consumed);
|
||
}
|
||
}
|
||
|
||
packet = new ParsedPacket(header, optional, fragments, fragmentHash);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ClientPacketFragment.Unpack (ClientPacketFragment.cs:10-23) — ACE's
|
||
/// COMPLETE inbound fragment validation: a 16-byte header, then
|
||
/// <c>Size − 16 >= 0</c> (:14-15) and <c>Size <= 464</c> (:17-18).
|
||
/// Deliberately looser than acdream's production
|
||
/// <c>MessageFragment.TryParseLayout</c>, which additionally rejects
|
||
/// <c>Count == 0</c> and <c>Index >= Count</c>: the double's C2S parse
|
||
/// path exists to characterize ACE, so it must accept everything ACE
|
||
/// accepts. ACE's <c>BinaryReader.ReadBytes</c> also tolerates a short
|
||
/// read at the end of the body (:20), producing a truncated payload
|
||
/// instead of a parse failure — modeled here by clamping.
|
||
/// </summary>
|
||
private static bool TryParseClientFragment(
|
||
ReadOnlySpan<byte> source,
|
||
out MessageFragment fragment,
|
||
out int consumed)
|
||
{
|
||
fragment = default;
|
||
consumed = 0;
|
||
|
||
// A header that cannot be read throws inside ACE's reader and is
|
||
// caught as "corrupt packet" (ClientPacket.cs:67-71).
|
||
if (source.Length < MessageFragmentHeader.Size)
|
||
return false;
|
||
|
||
MessageFragmentHeader header = MessageFragmentHeader.Unpack(source);
|
||
if (header.TotalSize < MessageFragmentHeader.Size)
|
||
return false; // ClientPacketFragment.cs:14-15
|
||
if (header.TotalSize > MessageFragmentHeader.MaxFragmentSize)
|
||
return false; // ClientPacketFragment.cs:17-18
|
||
|
||
int payloadLength = Math.Min(
|
||
header.TotalSize - MessageFragmentHeader.Size,
|
||
source.Length - MessageFragmentHeader.Size);
|
||
fragment = new MessageFragment(
|
||
header,
|
||
source.Slice(MessageFragmentHeader.Size, payloadLength).ToArray());
|
||
consumed = MessageFragmentHeader.Size + payloadLength;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A parsed, CRC-verifiable C2S packet. Buffered out-of-order packets are
|
||
/// stored in THIS form — ACE never re-verifies a buffered packet's CRC
|
||
/// (the key was consumed on first arrival).
|
||
/// </summary>
|
||
private sealed record ParsedPacket(
|
||
PacketHeader Header,
|
||
PacketHeaderOptional Optional,
|
||
List<MessageFragment> Fragments,
|
||
uint FragmentHash);
|
||
|
||
private readonly record struct OutboundDraft(
|
||
PacketHeaderFlags Flags,
|
||
byte[] Body,
|
||
int OptionalLength);
|
||
|
||
/// <summary>The cached ServerPacket surrogate — see FlushOne/TryRetransmit.</summary>
|
||
private sealed class CachedS2CPacket
|
||
{
|
||
public uint Sequence;
|
||
public PacketHeaderFlags Flags;
|
||
public ushort Time;
|
||
public byte[] Body = Array.Empty<byte>();
|
||
public int OptionalLength;
|
||
public uint IsaacXor;
|
||
}
|
||
|
||
/// <summary>ACE NetworkBundle surrogate (NetworkBundle.cs:6-63) — one per
|
||
/// GameMessageGroup, swapped out whole when it needs sending.</summary>
|
||
private sealed class PendingBundle
|
||
{
|
||
private readonly Queue<byte[]> _messages = new();
|
||
private bool _propChanged;
|
||
|
||
/// <summary>NetworkBundle.cs:11.</summary>
|
||
public bool NeedsSending => _propChanged || _messages.Count > 0;
|
||
/// <summary>NetworkBundle.cs:13.</summary>
|
||
public bool HasMoreMessages => _messages.Count > 0;
|
||
|
||
private float _clientTime = -1f;
|
||
/// <summary>NetworkBundle.cs:18-27 — -1f means "no echo pending".</summary>
|
||
public float ClientTime
|
||
{
|
||
get => _clientTime;
|
||
set { _clientTime = value; _propChanged = true; }
|
||
}
|
||
|
||
private bool _timeSync;
|
||
/// <summary>NetworkBundle.cs:29-38.</summary>
|
||
public bool TimeSync
|
||
{
|
||
get => _timeSync;
|
||
set { _timeSync = value; _propChanged = true; }
|
||
}
|
||
|
||
private bool _sendAck;
|
||
/// <summary>NetworkBundle.cs:40-49.</summary>
|
||
public bool SendAck
|
||
{
|
||
get => _sendAck;
|
||
set { _sendAck = value; _propChanged = true; }
|
||
}
|
||
|
||
/// <summary>NetworkBundle.cs:51.</summary>
|
||
public bool EncryptedChecksum { get; set; }
|
||
|
||
public void Enqueue(byte[] message) => _messages.Enqueue(message);
|
||
|
||
public byte[] Dequeue() => _messages.Dequeue();
|
||
}
|
||
|
||
/// <summary>
|
||
/// ACE's server-side MessageFragment (MessageFragment.cs:10-103): one
|
||
/// queued GameMessage plus the split bookkeeping SendBundle drives.
|
||
/// </summary>
|
||
private sealed class OutboundMessage
|
||
{
|
||
private readonly byte[] _data;
|
||
private readonly GameMessageGroup _group;
|
||
private ushort _index;
|
||
|
||
public uint Sequence { get; }
|
||
public ushort Count { get; }
|
||
public int DataRemaining { get; private set; }
|
||
public bool TailSent { get; private set; }
|
||
|
||
public int DataLength => _data.Length;
|
||
/// <summary>MessageFragment.cs:27-36.</summary>
|
||
public int NextSize =>
|
||
MessageFragmentHeader.Size
|
||
+ Math.Min(DataRemaining, MessageFragmentHeader.MaxFragmentDataSize);
|
||
/// <summary>MessageFragment.cs:38.</summary>
|
||
public int TailSize =>
|
||
MessageFragmentHeader.Size
|
||
+ (DataLength % MessageFragmentHeader.MaxFragmentDataSize);
|
||
|
||
public OutboundMessage(byte[] data, uint sequence, GameMessageGroup group)
|
||
{
|
||
_data = data;
|
||
_group = group;
|
||
Sequence = sequence;
|
||
DataRemaining = data.Length;
|
||
// :47 — ceil(length / 448).
|
||
Count = (ushort)Math.Ceiling(
|
||
(double)data.Length / MessageFragmentHeader.MaxFragmentDataSize);
|
||
_index = 0;
|
||
if (Count == 1)
|
||
TailSent = true; // :49-50
|
||
}
|
||
|
||
/// <summary>MessageFragment.cs:54-59.</summary>
|
||
public MessageFragment GetTailFragment()
|
||
{
|
||
var index = (ushort)(Count - 1);
|
||
TailSent = true;
|
||
return CreateFragment(index);
|
||
}
|
||
|
||
/// <summary>MessageFragment.cs:61-64.</summary>
|
||
public MessageFragment GetNextFragment() => CreateFragment(_index++);
|
||
|
||
/// <summary>MessageFragment.cs:66-102.</summary>
|
||
private MessageFragment CreateFragment(ushort index)
|
||
{
|
||
if (index >= Count)
|
||
throw new ArgumentOutOfRangeException(nameof(index), index, "index beyond computed count");
|
||
|
||
int position = index * MessageFragmentHeader.MaxFragmentDataSize;
|
||
int dataToSend = Math.Min(
|
||
DataLength - position,
|
||
MessageFragmentHeader.MaxFragmentDataSize);
|
||
if (DataRemaining < dataToSend)
|
||
throw new InvalidOperationException("more data to send than data remaining");
|
||
|
||
byte[] payload = _data.AsSpan(position, dataToSend).ToArray();
|
||
DataRemaining -= dataToSend;
|
||
|
||
return new MessageFragment(
|
||
new MessageFragmentHeader
|
||
{
|
||
Sequence = Sequence,
|
||
Id = GameMessageFragment.OutboundFragmentId, // :94 — 0x80000000
|
||
Count = Count,
|
||
TotalSize = (ushort)(MessageFragmentHeader.Size + dataToSend),
|
||
Index = index,
|
||
Queue = (ushort)_group,
|
||
},
|
||
payload);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// ACE MessageBuffer surrogate (MessageBuffer.cs:7-54). Deliberately a
|
||
/// LIST keyed on nothing, exactly like ACE: <c>TotalFragments</c> is taken
|
||
/// from the FIRST fragment seen and completion is a COUNT match, so a
|
||
/// later fragment claiming a bigger Count/Index neither resizes the buffer
|
||
/// nor throws.
|
||
/// </summary>
|
||
private sealed class PartialC2SMessage
|
||
{
|
||
private readonly List<(ushort Index, byte[] Payload)> _fragments = new();
|
||
private readonly int _totalFragments;
|
||
|
||
public PartialC2SMessage(int totalFragments) => _totalFragments = totalFragments;
|
||
|
||
/// <summary>MessageBuffer.cs:14.</summary>
|
||
public bool Complete => _fragments.Count == _totalFragments;
|
||
|
||
/// <summary>MessageBuffer.cs:22-31 — ignored once complete, and one
|
||
/// fragment per Index.</summary>
|
||
public void AddFragment(ushort index, byte[] payload)
|
||
{
|
||
if (Complete)
|
||
return;
|
||
foreach ((ushort existing, _) in _fragments)
|
||
{
|
||
if (existing == index)
|
||
return;
|
||
}
|
||
|
||
_fragments.Add((index, payload));
|
||
}
|
||
|
||
/// <summary>
|
||
/// MessageBuffer.TryGetMessage (:36-53) — sort by Index, concatenate,
|
||
/// and return NULL when the assembled stream is under the 4-byte
|
||
/// ClientMessage minimum (:49-50). A null here is a dropped message
|
||
/// that never advances the fragment gate.
|
||
/// </summary>
|
||
public byte[]? TryGetMessage()
|
||
{
|
||
_fragments.Sort((x, y) => x.Index - y.Index); // :38
|
||
|
||
int total = 0;
|
||
foreach ((_, byte[] payload) in _fragments)
|
||
total += payload.Length;
|
||
|
||
if (total < 4)
|
||
return null; // :49-50
|
||
|
||
byte[] message = new byte[total];
|
||
int offset = 0;
|
||
foreach ((_, byte[] payload) in _fragments)
|
||
{
|
||
payload.CopyTo(message.AsSpan(offset));
|
||
offset += payload.Length;
|
||
}
|
||
|
||
return message;
|
||
}
|
||
}
|
||
}
|