acdream/src/AcDream.Core.Net/Transport/TransportClock.cs
Erik 43e60a6971 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>
2026-07-29 12:21:37 +02:00

79 lines
2.9 KiB
C#

using System.Diagnostics;
namespace AcDream.Core.Net.Transport;
/// <summary>
/// The transport's monotonic time authority: an injectable timestamp source
/// (production default <see cref="Stopwatch.GetTimestamp"/>) driving retail's
/// 0.5-second interval counter. <see cref="IntervalId"/> is the value retail
/// writes into <c>ProtoHeader::interval_</c> — our
/// <see cref="Packets.PacketHeader.Time"/> — on rebuilt resend headers.
///
/// <para>
/// Retail oracle: <c>ClientFlowQueue::IncrementLocalInterval @ 0x00547F10</c>
/// (named-retail pseudo-C :338389) — the tail is
/// <c>CurLocalInterval_.intervalID_ += elapsedIntervals</c>, advanced by the
/// caller once per elapsed 0.5-s slice. Only the interval counter is in
/// N1 scope: the same function's every-6-intervals TimeSync/EchoRequest
/// (~3 s) and every-0xDC-intervals CICMD keepalive are campaign §5
/// deferrals (TS-58) — standalone-unsafe against ACE's watermark hole.
/// </para>
///
/// <para>
/// One clock owns every transport gate (campaign §5 AP-126 — retail's
/// cur/local clock split is immaterial to the gates we port). Single-threaded
/// like the rest of the transport: <see cref="Update"/> runs only from the
/// session sweep.
/// </para>
/// </summary>
internal sealed class TransportClock
{
private readonly Func<long> _timestampSource;
private readonly long _ticksPerInterval;
private long _intervalBaseTimestamp;
/// <summary>Timestamp ticks per second of the injected source.</summary>
public long Frequency { get; }
/// <summary>
/// Retail's 0.5-s interval counter (<c>CurLocalInterval_.intervalID_</c>).
/// Starts at 1; wraps with natural ushort arithmetic.
/// </summary>
public ushort IntervalId { get; private set; }
public TransportClock(
Func<long>? timestampSource = null,
long? frequency = null)
{
_timestampSource = timestampSource ?? Stopwatch.GetTimestamp;
Frequency = frequency ?? Stopwatch.Frequency;
if (Frequency < 2)
{
throw new ArgumentOutOfRangeException(
nameof(frequency),
"the interval clock needs at least 2 ticks per second");
}
_ticksPerInterval = Frequency / 2;
_intervalBaseTimestamp = _timestampSource();
IntervalId = 1;
}
/// <summary>Current raw timestamp from the injected source.</summary>
public long GetTimestamp() => _timestampSource();
/// <summary>
/// Advance <see cref="IntervalId"/> by however many whole 0.5-s
/// intervals have elapsed since the last update. Called once per sweep.
/// </summary>
public void Update()
{
long elapsed = _timestampSource() - _intervalBaseTimestamp;
if (elapsed < _ticksPerInterval)
return;
long steps = elapsed / _ticksPerInterval;
IntervalId = unchecked((ushort)(IntervalId + steps));
_intervalBaseTimestamp += steps * _ticksPerInterval;
}
}