feat(net): N1 - outbound sent-packet cache + resend on NAK
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>
This commit is contained in:
parent
e395861053
commit
43e60a6971
13 changed files with 1491 additions and 65 deletions
|
|
@ -8,6 +8,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Net.Cryptography;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Net.Transport;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
|
|
@ -66,9 +67,10 @@ internal sealed class NetClientWorldSessionTransport(IPEndPoint remote)
|
|||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Still deferred:</b> retransmit handling and unsolicited-disconnect
|
||||
/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout
|
||||
/// are live.
|
||||
/// <b>Still deferred:</b> inbound sequence-aligned ISAAC + client NAK
|
||||
/// emission (Campaign N slices N2/N4) and unsolicited-disconnect recovery.
|
||||
/// The outbound sent-packet cache + resend on server NAK (N1), ACKs, world
|
||||
/// updates, chat, and retail-ordered graceful logout are live.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldSession : IDisposable
|
||||
|
|
@ -672,12 +674,22 @@ public sealed class WorldSession : IDisposable
|
|||
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
|
||||
|
||||
private IsaacRandom? _inboundIsaac;
|
||||
private IsaacRandom? _outboundIsaac;
|
||||
private ushort _sessionClientId;
|
||||
private ushort _sessionIteration;
|
||||
private bool _transportNegotiated;
|
||||
private uint _clientPacketSequence;
|
||||
private uint _fragmentSequence = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N1: the reliable outbound transport — outbound
|
||||
/// ISAAC, packet/fragment sequences, sent-packet cache, resend on NAK.
|
||||
/// Constructed at ISAAC-seeding time in <see cref="Connect"/>; null
|
||||
/// before negotiation (reliable sends are impossible then anyway — the
|
||||
/// keystream does not exist yet).
|
||||
/// </summary>
|
||||
private ReliableTransport? _transport;
|
||||
|
||||
/// <summary>Test seam: transport counters + cache depth for the
|
||||
/// conformance/loss suites. Null before negotiation.</summary>
|
||||
internal ReliableTransport? Transport => _transport;
|
||||
|
||||
// Movement sequence counters — echoed back in every MoveToState and
|
||||
// AutonomousPosition so the server can detect stale/reordered packets.
|
||||
|
|
@ -858,14 +870,22 @@ public sealed class WorldSession : IDisposable
|
|||
byte[] clientSeedBytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
|
||||
_inboundIsaac = new IsaacRandom(serverSeedBytes);
|
||||
_outboundIsaac = new IsaacRandom(clientSeedBytes);
|
||||
_sessionClientId = (ushort)opt.ConnectRequestClientId;
|
||||
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
|
||||
// generation into connection-level control packets, including the
|
||||
// final disconnect. ACE currently emits iteration 1.
|
||||
_sessionIteration = connectRequestIteration;
|
||||
// N1: the reliable transport is born at ISAAC-seeding time, owning
|
||||
// the outbound keystream + packet/fragment sequences the session
|
||||
// used to hold directly. 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.
|
||||
_transport = new ReliableTransport(
|
||||
new IsaacRandom(clientSeedBytes),
|
||||
_sessionClientId,
|
||||
datagram => _net.Send(datagram));
|
||||
_transportNegotiated = true;
|
||||
_clientPacketSequence = 2;
|
||||
|
||||
// Publish only after the receiver identity and crypto state are fully
|
||||
// committed. A synchronous App callback may throw or request teardown;
|
||||
|
|
@ -881,10 +901,13 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
Transition(State.InCharacterSelect);
|
||||
|
||||
// Step 4: drain until CharacterList arrives
|
||||
// Step 4: drain until CharacterList arrives. The transport sweep
|
||||
// runs inside this blocking pump too (campaign landmine #8): the
|
||||
// first server NAK can precede the first Tick().
|
||||
while (DateTime.UtcNow < deadline && Characters is null)
|
||||
{
|
||||
PumpOnce();
|
||||
SweepTransport();
|
||||
}
|
||||
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
|
||||
}
|
||||
|
|
@ -909,11 +932,15 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
|
||||
|
||||
// Wait for CharacterEnterWorldServerReady (0xF7DF)
|
||||
// Wait for CharacterEnterWorldServerReady (0xF7DF). Sweep inside
|
||||
// the blocking pump (campaign landmine #8): the EnterWorld
|
||||
// CreateObject flood — and any NAK it provokes — precedes the
|
||||
// first Tick().
|
||||
bool serverReady = false;
|
||||
while (DateTime.UtcNow < deadline && !serverReady)
|
||||
{
|
||||
var drained = PumpOnce(out var opcodes);
|
||||
SweepTransport();
|
||||
if (!drained) continue;
|
||||
foreach (var op in opcodes)
|
||||
if (op == 0xF7DFu) { serverReady = true; break; }
|
||||
|
|
@ -1020,9 +1047,28 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
if (NetDiagnostics.ProbeNet)
|
||||
ProbeNetTickCadence(start, processed, budgetBroke);
|
||||
// N1: the transport sweep runs at the end of EVERY Tick, after the
|
||||
// budget break — a deferred inbound tail must not defer a due
|
||||
// resend past this frame.
|
||||
SweepTransport();
|
||||
return processed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1: one reliable-transport pump slice (retail
|
||||
/// <c>PacketController::UseTime @ 0x005410D0</c> shape): interval clock
|
||||
/// forward, pending NAKed resends out, acked cache pruned. Gated on
|
||||
/// negotiation — ACE's <c>Session.CheckState</c> silently discards
|
||||
/// pre-negotiation control traffic (campaign landmine #8), and the
|
||||
/// transport does not exist before the ISAAC seeds do.
|
||||
/// </summary>
|
||||
private void SweepTransport()
|
||||
{
|
||||
if (!_transportNegotiated)
|
||||
return;
|
||||
_transport?.Sweep();
|
||||
}
|
||||
|
||||
// #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
|
||||
|
|
@ -1257,6 +1303,31 @@ public sealed class WorldSession : IDisposable
|
|||
// acceptance, before any heavy render-thread message handling.
|
||||
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
|
||||
|
||||
PacketHeader serverHeader = dec.Packet.Header;
|
||||
|
||||
// N1: consume the transport control surfaces FIRST, before the
|
||||
// reflex ack below (which still fires unchanged this slice; the
|
||||
// AckNakScheduler replaces it in N3).
|
||||
if (_transport is { } transport)
|
||||
{
|
||||
// Server NAK (RequestRetransmit 0x1000): merge the requested
|
||||
// ids into the pending-resend list; ids[0] doubles as retail's
|
||||
// implicit cumulative ack (RecipientData::ProcessNaks
|
||||
// @ 0x00547010). The resends go out on the next sweep.
|
||||
if ((serverHeader.Flags & PacketHeaderFlags.RequestRetransmit) != 0
|
||||
&& dec.Packet.Optional.RetransmitRequestCount > 0)
|
||||
{
|
||||
transport.Outbound.OnRetransmitRequest(
|
||||
dec.Packet.Optional.RetransmitRequestBytes.Span,
|
||||
dec.Packet.Optional.RetransmitRequestCount);
|
||||
}
|
||||
|
||||
// Cumulative ack (AckSequence 0x4000): wrap-safe max into the
|
||||
// watermark; the cache prunes strictly below it on the sweep.
|
||||
if ((serverHeader.Flags & PacketHeaderFlags.AckSequence) != 0)
|
||||
transport.Outbound.OnAckSequence(dec.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
|
||||
|
|
@ -1264,7 +1335,6 @@ public sealed class WorldSession : IDisposable
|
|||
// 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).
|
||||
PacketHeader serverHeader = dec.Packet.Header;
|
||||
if (serverHeader.Sequence > 0
|
||||
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
|
||||
{
|
||||
|
|
@ -2242,28 +2312,16 @@ public sealed class WorldSession : IDisposable
|
|||
ProbeNetLogOutbound(gameMessageBody, queue);
|
||||
try
|
||||
{
|
||||
Span<byte> datagram = stackalloc byte[
|
||||
PacketHeader.Size
|
||||
+ MessageFragmentHeader.MaxFragmentSize];
|
||||
int fragmentLength =
|
||||
GameMessageFragment.WriteSingleFragment(
|
||||
datagram.Slice(PacketHeader.Size),
|
||||
_fragmentSequence++,
|
||||
queue,
|
||||
gameMessageBody);
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Sequence = _clientPacketSequence++,
|
||||
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
|
||||
Id = _sessionClientId,
|
||||
};
|
||||
int datagramLength = PacketCodec.FinalizeInPlace(
|
||||
header,
|
||||
datagram,
|
||||
fragmentLength,
|
||||
optionalLength: 0,
|
||||
_outboundIsaac);
|
||||
_net.Send(datagram.Slice(0, datagramLength));
|
||||
// N1: the reliable transport owns encode + send + cache. Wire
|
||||
// shape is unchanged; the datagram is additionally cached for
|
||||
// resend on server NAK. Pre-negotiation reliable sends were
|
||||
// always impossible (no outbound keystream existed) — the
|
||||
// exception simply names the state now.
|
||||
ReliableTransport transport = _transport
|
||||
?? throw new InvalidOperationException(
|
||||
"reliable send before transport negotiation — "
|
||||
+ "Connect() must seed ISAAC first");
|
||||
transport.Outbound.SendGameMessage(gameMessageBody, queue);
|
||||
}
|
||||
catch (Exception ex) when (ProbeNetLogOutboundFault(ex))
|
||||
{
|
||||
|
|
@ -2295,8 +2353,10 @@ public sealed class WorldSession : IDisposable
|
|||
detail = $" act=0x{act:X4} gseq={gseq}";
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"[net-out] op=0x{op:X4}{detail} q={queue} fseq={_fragmentSequence}"
|
||||
+ $" pseq={_clientPacketSequence} len={body.Length}"
|
||||
$"[net-out] op=0x{op:X4}{detail} q={queue}"
|
||||
+ $" fseq={_transport?.Outbound.FragmentSequence ?? 0}"
|
||||
+ $" pseq={_transport?.Outbound.PeekNextPacketSequence ?? 0}"
|
||||
+ $" len={body.Length}"
|
||||
+ $" tid={Environment.CurrentManagedThreadId} st={CurrentState}");
|
||||
}
|
||||
|
||||
|
|
@ -2345,10 +2405,10 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
|
||||
// ack headers. We mirror that — acks borrow the most recently issued
|
||||
// client sequence rather than consuming a new one.
|
||||
uint ackHeaderSequence = _clientPacketSequence > 0
|
||||
? _clientPacketSequence - 1
|
||||
: 0u;
|
||||
// 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
|
||||
{
|
||||
|
|
@ -2433,6 +2493,9 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
_netCancel.Dispose();
|
||||
|
||||
// N1: return every rented sent-packet cache buffer before the
|
||||
// socket goes away.
|
||||
_transport?.Dispose();
|
||||
_net.Dispose();
|
||||
Transition(State.Disconnected);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue