fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -275,5 +275,6 @@ internal sealed class AckNakScheduler
outboundIsaac: null);
_send(datagram.Slice(0, datagramLength));
_stats.NaksSent++;
_stats.NakIdsSent += count;
}
}

View file

@ -263,6 +263,23 @@ internal sealed class OutboundFlowQueue : IDisposable
{
for (int i = 0; i < _pendingResends.Count; i++)
{
// A later cumulative ACK can overtake an earlier NAK in the
// receive-owner queue before this frame reaches its sweep.
// Such a request is stale: the server has already advanced
// its ordered watermark past the requested packet. Replaying
// the old encrypted key is actively harmful against ACE,
// whose CRC/key search runs before duplicate-sequence
// rejection and can burn the remaining 256-word window.
// The equal case is NOT stale: ids[0] of an ordinary NAK is
// also its implicit ACK and still needs retransmission.
if (AckWatermark != 0
&& SequenceMath.IsNewer(
AckWatermark,
_pendingResends[i]))
{
continue;
}
// A pending id can leave the cache between NAK arrival and
// this sweep only if a newer ack already covered it — the
// server has it; serving nothing is correct.

View file

@ -41,6 +41,9 @@ internal sealed class ReliableTransport : IDisposable
public TransportStats Stats { get; }
/// <summary>Retail's 40-heartbeat packet-loss percentage.</summary>
public double PacketLossPercentage => _packetLoss.Percentage;
/// <summary>
/// N6: retail's ephemeral-info flush cadence
/// (<c>Indicator::FlushTimedOutEphInfo @ 0x0054A3D0</c>, the x87 compare
@ -51,6 +54,7 @@ internal sealed class ReliableTransport : IDisposable
public const double AssemblerSweepSeconds = 5.0;
private readonly FragmentAssembler? _assembler;
private readonly RetailPacketLossAverager _packetLoss;
private readonly long _assemblerSweepTicks;
private long _assemblerSweepTimestamp;
@ -71,13 +75,19 @@ internal sealed class ReliableTransport : IDisposable
_assemblerSweepTicks =
(long)Math.Round(AssemblerSweepSeconds * Clock.Frequency);
_assemblerSweepTimestamp = Clock.GetTimestamp();
void CountedSend(ReadOnlySpan<byte> datagram)
{
send(datagram);
Stats.PacketsSent++;
}
Outbound = new OutboundFlowQueue(
outboundIsaac,
sessionClientId,
sessionIteration,
Clock,
Stats,
send,
CountedSend,
pool);
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
Scheduler = new AckNakScheduler(
@ -87,8 +97,9 @@ internal sealed class ReliableTransport : IDisposable
sessionClientId,
sessionIteration,
Stats,
send);
CountedSend);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
_packetLoss = new RetailPacketLossAverager(Clock, Stats);
}
/// <summary>Last reliable sequence on the wire — the value unsequenced
@ -112,6 +123,12 @@ internal sealed class ReliableTransport : IDisposable
{
Clock.Update();
long now = Clock.GetTimestamp();
// Retail snapshots the preceding two-second interval inside
// ClientNet::ProcessConnection before this heartbeat emits its
// ACK/NAK control traffic. Keep boundary control packets in the new
// sample rather than attributing them to the interval that just
// ended.
_packetLoss.Sweep(now, Stats);
Scheduler.Sweep(now);
Outbound.TransmitPendingResends();

View file

@ -0,0 +1,124 @@
namespace AcDream.Core.Net.Transport;
/// <summary>
/// Retail's packet-loss telemetry window from
/// <c>CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610</c> and
/// <c>CLinkStatusAverages::AddSnapshot @ 0x00546650</c>.
/// </summary>
/// <remarks>
/// <para>
/// <c>ClientNet::ProcessConnection @ 0x00545450</c> snapshots the four
/// packet counters on its 2.0-second heartbeat. Each counter is a
/// <c>CAverager&lt;unsigned short,40&gt;</c>. The loss function divides the
/// windowed NAK + retransmit totals by received + sent totals and reports a
/// percentage. A zero denominator reports zero.
/// </para>
/// <para>
/// The input values here are cumulative acdream counters; each sample stores
/// their delta since the preceding heartbeat. The unchecked ushort conversion
/// preserves retail's snapshot-field width.
/// </para>
/// </remarks>
internal sealed class RetailPacketLossAverager
{
public const int WindowSize = 40;
public const double SnapshotSeconds = 2.0;
private readonly Sample[] _samples = new Sample[WindowSize];
private readonly long _snapshotTicks;
private long _snapshotTimestamp;
private long _lastPacketsSent;
private long _lastRetransmitsSent;
private long _lastPacketsReceived;
private long _lastNakIdsSent;
private long _sentTotal;
private long _retransmitTotal;
private long _receivedTotal;
private long _nakTotal;
private int _next;
private int _count;
public RetailPacketLossAverager(TransportClock clock, TransportStats stats)
{
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(stats);
_snapshotTicks = (long)Math.Round(SnapshotSeconds * clock.Frequency);
_snapshotTimestamp = clock.GetTimestamp();
CaptureBaselines(stats);
}
public double Percentage
{
get
{
long denominator = _receivedTotal + _sentTotal;
return denominator <= 0
? 0d
: 100d * (_nakTotal + _retransmitTotal) / denominator;
}
}
public void Sweep(long now, TransportStats stats)
{
ArgumentNullException.ThrowIfNull(stats);
if (now - _snapshotTimestamp < _snapshotTicks)
return;
// ProcessConnection records one snapshot when the heartbeat branch
// runs; it does not synthesize empty samples for skipped frames.
_snapshotTimestamp = now;
var sample = new Sample(
DeltaAsRetailUShort(stats.PacketsSent, ref _lastPacketsSent),
DeltaAsRetailUShort(stats.ResendsSent, ref _lastRetransmitsSent),
DeltaAsRetailUShort(stats.PacketsReceived, ref _lastPacketsReceived),
DeltaAsRetailUShort(stats.NakIdsSent, ref _lastNakIdsSent));
if (_count == WindowSize)
Remove(_samples[_next]);
else
_count++;
_samples[_next] = sample;
_next = (_next + 1) % WindowSize;
Add(sample);
}
private void CaptureBaselines(TransportStats stats)
{
_lastPacketsSent = stats.PacketsSent;
_lastRetransmitsSent = stats.ResendsSent;
_lastPacketsReceived = stats.PacketsReceived;
_lastNakIdsSent = stats.NakIdsSent;
}
private static ushort DeltaAsRetailUShort(long current, ref long previous)
{
long delta = current - previous;
previous = current;
return unchecked((ushort)Math.Max(0L, delta));
}
private void Add(Sample sample)
{
_sentTotal += sample.Sent;
_retransmitTotal += sample.Retransmitted;
_receivedTotal += sample.Received;
_nakTotal += sample.Naked;
}
private void Remove(Sample sample)
{
_sentTotal -= sample.Sent;
_retransmitTotal -= sample.Retransmitted;
_receivedTotal -= sample.Received;
_nakTotal -= sample.Naked;
}
private readonly record struct Sample(
ushort Sent,
ushort Retransmitted,
ushort Received,
ushort Naked);
}

View file

@ -9,6 +9,15 @@ namespace AcDream.Core.Net.Transport;
/// </summary>
internal sealed class TransportStats
{
/// <summary>Checksum-valid post-negotiation datagrams received. Used by
/// retail's 2-second/40-sample link-status loss window.</summary>
public long PacketsReceived;
/// <summary>Datagrams successfully emitted after transport negotiation.
/// Includes reliable traffic, retransmits, cumulative ACKs, and NAKs,
/// matching retail's packet-level denominator.</summary>
public long PacketsSent;
/// <summary>Datagrams re-emitted in response to a server NAK.</summary>
public long ResendsSent;
@ -57,6 +66,11 @@ internal sealed class TransportStats
/// <c>ReceiverData::GetNaks @ 0x005490C0</c>, ≤114 ids each).</summary>
public long NaksSent;
/// <summary>Total missing sequence ids carried by emitted NAKs. Retail's
/// <c>CLinkStatusSnapshot::nPktsNAKed</c> counts missing packets, not the
/// number of control datagrams used to request them.</summary>
public long NakIdsSent;
/// <summary>N4: mis-parked keystream words reclaimed from validated
/// cleartext <c>RejectRetransmit</c> sequences (the AD-51 ACE
/// adaptation; always zero against a retail server).</summary>