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

@ -81,6 +81,7 @@ public sealed class DatSoundCache
private readonly Dictionary<uint, LinkedListNode<WaveEntry>> _waveEntries = new();
private readonly LinkedList<WaveEntry> _waveLru = new();
private readonly long _maxWaveBytes;
private readonly Action<uint>? _afterInitialWaveMiss;
private long _residentWaveBytes;
private long _hits;
private long _misses;
@ -98,11 +99,24 @@ public sealed class DatSoundCache
/// exercised deterministically without allocating tens of megabytes.
/// </summary>
public DatSoundCache(IDatObjectSource dats, long maxWaveBytes)
: this(dats, maxWaveBytes, afterInitialWaveMiss: null)
{
}
/// <summary>
/// Deterministic concurrency seam used to park a caller after its fast
/// resident lookup but before the authoritative locked recheck.
/// </summary>
internal DatSoundCache(
IDatObjectSource dats,
long maxWaveBytes,
Action<uint>? afterInitialWaveMiss)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1);
_dats = dats;
_maxWaveBytes = maxWaveBytes;
_afterInitialWaveMiss = afterInitialWaveMiss;
}
/// <summary>
@ -128,14 +142,37 @@ public sealed class DatSoundCache
return null;
}
Interlocked.Increment(ref _misses);
_afterInitialWaveMiss?.Invoke(waveId);
Lazy<WaveData?> lazy = _inflight.GetOrAdd(
waveId,
static (id, self) => new Lazy<WaveData?>(
() => self.DecodeUncached(id),
LazyThreadSafetyMode.ExecutionAndPublication),
this);
Lazy<WaveData?> lazy;
lock (_gate)
{
// A caller can pause after the fast miss while another caller
// decodes, admits, and removes the in-flight Lazy. Recheck the
// authoritative caches and acquire/create the Lazy under the same
// gate used by admission so that stale caller cannot start a
// second decode after publication.
if (_waveEntries.TryGetValue(waveId, out var node))
{
Interlocked.Increment(ref _hits);
Touch(node);
return node.Value.Wave;
}
if (_negativeWaveIds.ContainsKey(waveId))
{
Interlocked.Increment(ref _hits);
return null;
}
Interlocked.Increment(ref _misses);
lazy = _inflight.GetOrAdd(
waveId,
static (id, self) => new Lazy<WaveData?>(
() => self.DecodeUncached(id),
LazyThreadSafetyMode.ExecutionAndPublication),
this);
}
try
{
WaveData? decoded = lazy.Value;

View file

@ -179,14 +179,28 @@ public sealed class ChatLog
/// <summary>PlayerKilled (0x019E) — death announcement.</summary>
/// <remarks>
/// Death messages are routed as <see cref="ChatKind.System"/> so they
/// share styling with other server announcements. The
/// Retail <c>ClientCombatSystem::HandlePlayerDeathEvent @0x0056C320</c>
/// suppresses this bystander-facing line when the local player is either
/// the victim or killer; those participants receive their dedicated
/// 0x01AC/0x01AD notification instead. Death messages that survive that
/// gate are routed as <see cref="ChatKind.System"/> so they share styling
/// with other server announcements. The
/// <c>SenderGuid</c> field carries the victim guid; the
/// <c>ChannelId</c> field carries the killer guid (a small misuse
/// of the field but avoids a schema change).
/// </remarks>
public void OnPlayerKilled(string deathMessage, uint victimGuid, uint killerGuid)
public void OnPlayerKilled(
string deathMessage,
uint victimGuid,
uint killerGuid,
uint localPlayerGuid = 0u)
{
if (localPlayerGuid != 0u
&& (localPlayerGuid == victimGuid || localPlayerGuid == killerGuid))
{
return;
}
Append(new ChatEntry(
Kind: ChatKind.System,
Sender: "",

View file

@ -192,11 +192,12 @@ public sealed class PhysicsTimestampGate
_timestamps[ForcePosition] = forcePosition;
// SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local
// FORCE_POSITION whose teleport is exactly equal blips immediately,
// assigns POSITION_TS directly (even equal/older), preserves the
// current heading, sends a position event, and returns WITHOUT
// advancing TELEPORT_TS.
if (teleport == _timestamps[Teleport])
// FORCE_POSITION whose teleport stamp is not older (equal OR
// newer) blips immediately, assigns POSITION_TS directly (even
// equal/older), preserves the current heading, sends a position
// event, and returns WITHOUT advancing TELEPORT_TS. The same
// wrap-safe predicate is used by IsFreshTeleportStart above.
if (!IsNewer(teleport, _timestamps[Teleport]))
{
_timestamps[Position] = position;
return PositionTimestampDisposition.ForcePosition;

View file

@ -16,26 +16,13 @@ namespace AcDream.Core.Physics;
/// transitional blending.
///
/// <para>
/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as
/// an inline, App-layer-only implementation for the "legacy" (no
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path —
/// <c>LiveEntityAnimationPresenter.Present</c>'s non-sequencer branch
/// (<c>CurrFrame += legacyAdvanceSeconds * Framerate</c> with the same
/// modulo wrap) and its private <c>TryResolvePartFrame</c> helper (the same
/// frame-bracket lerp/slerp). That call site has a live entity, a
/// This is the shared implementation used by both the "legacy" (no
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path and the chargen
/// preview. The live presenter has a live entity, a
/// <c>LiveEntityRuntime</c> membership, and per-tick elapsed time supplied by
/// the render loop; the chargen preview has none of that (there is no live
/// entity — character creation hasn't happened yet), so it cannot reuse that
/// class directly. Rather than re-typing the same formula a second time,
/// this Core, pure, unit-testable class is the shared primitive: the
/// chargen preview (<c>AcDream.App.Rendering.ChargenPreviewAnimator</c>)
/// consumes it directly, and it is safe for a future pass to redirect
/// <c>LiveEntityAnimationPresenter</c>'s inline copy through it as a
/// behavior-preserving mechanical follow-up (not done here — that file is
/// live, heavily tested production entity-rendering code with zero relation
/// to this preview-only feature, so touching it is out of this slice's
/// blast radius by design, not oversight). Tracked as
/// <c>docs/ISSUES.md</c> #403 so the follow-up has an owner.
/// entity — character creation hasn't happened yet), so neither consumer can
/// own the primitive. Keeping it here prevents the two paths from drifting.
/// </para>
/// </summary>
public static class RetailAnimationCyclePlayback
@ -43,14 +30,15 @@ public static class RetailAnimationCyclePlayback
/// <summary>
/// Advances <paramref name="currFrame"/> by <c>elapsedSeconds * framerate</c>
/// and wraps it back into <c>[lowFrame, highFrame]</c> with the SAME modulo
/// shape <c>LiveEntityAnimationPresenter.Present</c>'s legacy branch uses
/// shape retail playback and the live presenter's legacy branch use
/// (<c>over % (span + 1)</c>, not a plain clamp — a frame position that
/// overshoots the end by more than one span wraps around more than once
/// rather than sticking at the boundary, matching a long stall/resume).
/// Returns <paramref name="currFrame"/> unchanged for a degenerate cycle
/// (<paramref name="highFrame"/> &lt;= <paramref name="lowFrame"/>), a
/// non-positive <paramref name="framerate"/>, or a non-positive
/// <paramref name="elapsedSeconds"/>.
/// (<paramref name="highFrame"/> &lt;= <paramref name="lowFrame"/>) or a
/// non-positive <paramref name="elapsedSeconds"/>. A negative framerate
/// advances backward and clamps at the low frame, matching the former
/// live-presenter implementation exactly.
/// </summary>
public static float Advance(
float currFrame,
@ -60,7 +48,7 @@ public static class RetailAnimationCyclePlayback
float elapsedSeconds)
{
int span = highFrame - lowFrame;
if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f)
if (span <= 0 || elapsedSeconds <= 0f)
return currFrame;
float next = currFrame + elapsedSeconds * framerate;
@ -84,8 +72,7 @@ public static class RetailAnimationCyclePlayback
/// back to <paramref name="lowFrame"/>). Returns <c>false</c> — with
/// <c>default</c> outputs — when <paramref name="partIndex"/> is outside
/// the bracketing frame's part list, matching
/// <c>LiveEntityAnimationPresenter.TryResolvePartFrame</c>'s no-
/// sequence-frames branch exactly.
/// the live presenter's no-sequence-frames branch exactly.
/// </summary>
public static bool TryInterpolatePart(
Animation animation,

View file

@ -505,6 +505,15 @@ public sealed class WorldTimeService
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
/// <summary>
/// Interpolate this clock's active day-group provider at an explicit day
/// fraction without changing the clock. Retail <c>LScape::SetDay</c>
/// uses this distinction: the sky continues advancing normally while
/// landscape lighting is sampled at noon.
/// </summary>
public SkyKeyframe SkyAtDayFraction(float dayFraction) =>
_sky.Interpolate(dayFraction);
/// <summary>Convenience: current sun direction from derived sky state.</summary>
public Vector3 CurrentSunDirection =>
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);