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

@ -109,10 +109,17 @@ public static class InteriorEntityPartition
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint neverCullLandblockId = 0u)
{
var result = new Result();
Partition(result, visibleCells, landblockEntries);
Partition(
result,
visibleCells,
landblockEntries,
frustum,
neverCullLandblockId);
return result;
}
@ -131,11 +138,23 @@ public static class InteriorEntityPartition
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint neverCullLandblockId = 0u)
{
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
if (!IsLandblockVisible(
entry.LandblockId,
entry.AabbMin,
entry.AabbMax,
frustum,
neverCullLandblockId))
{
continue;
}
foreach (var e in entry.Entities)
{
if (e.MeshRefs.Count == 0) continue;
@ -176,11 +195,18 @@ public static class InteriorEntityPartition
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
IObserver? observer)
IObserver? observer,
FrustumPlanes? frustum = null,
uint neverCullLandblockId = 0u)
{
if (observer is null)
{
Partition(result, visibleCells, landblockEntries);
Partition(
result,
visibleCells,
landblockEntries,
frustum,
neverCullLandblockId);
return;
}
@ -190,6 +216,16 @@ public static class InteriorEntityPartition
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
if (!IsLandblockVisible(
entry.LandblockId,
entry.AabbMin,
entry.AabbMax,
frustum,
neverCullLandblockId))
{
continue;
}
foreach (var e in entry.Entities)
{
if (e.MeshRefs.Count == 0) continue;
@ -247,4 +283,14 @@ public static class InteriorEntityPartition
/// <inheritdoc cref="IsIndoorCellId(uint)"/>
public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c);
private static bool IsLandblockVisible(
uint landblockId,
Vector3 aabbMin,
Vector3 aabbMax,
FrustumPlanes? frustum,
uint neverCullLandblockId) =>
frustum is null
|| landblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax);
}

View file

@ -159,16 +159,12 @@ internal sealed class LiveEntityAnimationPresenter
continue;
if (span > 0 && legacyAdvanceSeconds > 0f)
{
animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate;
if (animation.CurrFrame > animation.HighFrame)
{
float over = animation.CurrFrame - animation.LowFrame;
animation.CurrFrame = animation.LowFrame + (over % (span + 1));
}
else if (animation.CurrFrame < animation.LowFrame)
{
animation.CurrFrame = animation.LowFrame;
}
animation.CurrFrame = RetailAnimationCyclePlayback.Advance(
animation.CurrFrame,
animation.LowFrame,
animation.HighFrame,
animation.Framerate,
legacyAdvanceSeconds);
}
}
@ -279,33 +275,14 @@ internal sealed class LiveEntityAnimationPresenter
return false;
}
int frameIndex = (int)Math.Floor(animation.CurrFrame);
if (frameIndex < animation.LowFrame
|| frameIndex > animation.HighFrame
|| frameIndex >= animation.Animation.PartFrames.Count)
{
frameIndex = animation.LowFrame;
}
int nextIndex = frameIndex + 1;
if (nextIndex > animation.HighFrame
|| nextIndex >= animation.Animation.PartFrames.Count)
{
nextIndex = animation.LowFrame;
}
float t = Math.Clamp(animation.CurrFrame - frameIndex, 0f, 1f);
var frames = animation.Animation.PartFrames[frameIndex].Frames;
var nextFrames = animation.Animation.PartFrames[nextIndex].Frames;
if (partIndex < frames.Count)
{
var first = frames[partIndex];
var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first;
origin = Vector3.Lerp(first.Origin, next.Origin, t);
orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t);
return true;
}
origin = default;
orientation = default;
return false;
return RetailAnimationCyclePlayback.TryInterpolatePart(
animation.Animation,
animation.CurrFrame,
animation.LowFrame,
animation.HighFrame,
partIndex,
out origin,
out orientation);
}
private static void EnsureRetainedPoses(LiveEntityAnimationState animation)

View file

@ -239,7 +239,9 @@ public sealed class RetailPViewRenderer
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver);
_partitionObserver,
ctx.Frustum,
ctx.PlayerLandblockId ?? 0u);
partition = _partitionResult;
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using AcDream.Core.Meshing;
@ -52,15 +53,17 @@ public sealed partial class SkyRenderer : IDisposable
// Lazily-built GPU resources per sky-GfxObj.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
// When did we start running — used to accumulate TexVelocityX/Y over
// real time (independent of the day-fraction clock).
private readonly DateTime _startedAt = DateTime.UtcNow;
// Retail advances animated texture coordinates from Timer::cur_time
// deltas in CPhysics::UseTime. Stopwatch is the matching monotonic clock:
// unlike wall time, OS clock synchronization cannot make rain/cloud UVs
// jump forward or backward.
private readonly long _animationStartedAtTimestamp = Stopwatch.GetTimestamp();
/// <summary>
/// Campaign V slice V7: pins the sky's scroll phase to a fixed number of
/// seconds instead of reading the wall clock, so two launches agree.
/// seconds instead of advancing the live animation clock, so two launches agree.
/// <c>null</c> — the default, and what every ordinary run gets — keeps the
/// wall clock.
/// monotonic real-elapsed-time clock.
///
/// <para><b>Why the sky needs its own pin when the world clock is already
/// pinnable.</b> Two independent clocks drive this renderer. The Dereth clock
@ -239,7 +242,9 @@ public sealed partial class SkyRenderer : IDisposable
var replaces = PickReplaces(group, dayFraction);
float secondsSinceStart = AnimationPhaseSecondsOverride
?? (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
?? ElapsedAnimationSeconds(
_animationStartedAtTimestamp,
Stopwatch.GetTimestamp());
for (int i = 0; i < group.SkyObjects.Count; i++)
{
@ -454,6 +459,9 @@ public sealed partial class SkyRenderer : IDisposable
}
}
internal static float ElapsedAnimationSeconds(long startTimestamp, long currentTimestamp)
=> (float)Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp).TotalSeconds;
/// <summary>
/// Campaign V slice V6e: the table slot for one (texture, wrap-mode) pair,
/// interning a resident bindless handle on first use.

View file

@ -43,6 +43,9 @@ namespace AcDream.App.Rendering;
/// </summary>
internal sealed class SkyPesFrameController
{
private static readonly Matrix4x4[] IdentityPartPose =
[Matrix4x4.Identity];
private readonly record struct SkyPesKey(
int ObjectIndex,
uint GfxObjId,
@ -122,12 +125,23 @@ internal sealed class SkyPesFrameController
? ParticleRenderPass.SkyPostScene
: ParticleRenderPass.SkyPreScene;
_particles.SetEntityRenderPass(ownerId, renderPass);
// The sky cell follows the viewer. Keep the script dispatch
// anchor on that same current-frame pose: SoundTweaked hooks in
// the Rainy carriers are ordinary world sounds, and a stale
// creation-time anchor falls beyond retail's audible radius as
// soon as login/teleport/movement displaces the camera.
_scripts.SetOwnerAnchor(ownerId, cameraWorldPosition);
Quaternion rotation = Rotation(skyObject, dayFraction);
_poses.Publish(
ownerId,
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cameraWorldPosition),
Array.Empty<Matrix4x4>(),
// Dereth's scripted sky carriers (including lightning Setup
// 0x02000BA6) are one-part dummy anchors whose default part-0
// frame is identity. Their CreateParticle hooks target part
// 0, not the -1 root sentinel, so a root-only synthetic pose
// makes a live carrier look pose-less to ParticleHookSink.
IdentityPartPose,
cellId: 0u);
if (_active.Contains(key) || _missing.Contains(key))

View file

@ -264,11 +264,8 @@ public sealed unsafe partial class EnvCellRenderer
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
int groupIndex = drawRange.GroupIndex;
var cullMode = (CullMode)(groupIndex % 4);
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
// uniformly, but the room surfaces need to be visible from inside.
// Render cell polys double-sided, exactly as the GL arm does.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(groupIndex % 4));
bool isAdditive = groupIndex >= 4;
IGpuPipeline rangeBasePipeline = isAdditive
@ -361,9 +358,8 @@ public sealed unsafe partial class EnvCellRenderer
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
var cullMode = (CullMode)(drawRange.GroupIndex % 4);
if (cullMode == CullMode.Landblock)
cullMode = CullMode.None;
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(drawRange.GroupIndex % 4));
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
@ -417,6 +413,25 @@ public sealed unsafe partial class EnvCellRenderer
}
}
/// <summary>
/// Resolves a CellStruct polygon's DAT <c>sides_type</c> to the render
/// state used by retail's constructed EnvCell mesh. The similarly named
/// <see cref="CullMode"/> values on <c>Polygon.SidesType</c> are not GPU
/// cull states: 0 emits the positive face, 1 emits that face twice with
/// reversed indices, and 2 emits the positive and negative surface.
/// <c>D3DPolyRender::ConstructMesh @ 0x0059DFA0</c> performs that geometry
/// expansion, then every subset is drawn with <c>D3DCULL_CW</c> through
/// <c>RenderMeshSubset @ 0x0059CA10</c>. <see cref="MeshExtractor"/>
/// already performs the identical expansion, so every shell batch must
/// cull clockwise here. Returning <see cref="CullMode.None"/> for DAT 0
/// was #178's Phase-A8 double-sided stopgap.
/// </summary>
internal static CullMode ResolveRetailCellShellCullMode(CullMode sidesType)
{
_ = sidesType;
return CullMode.Clockwise;
}
/// <summary>
/// Reserves this frame's ring, copies into it, and binds the slice. A
/// logically empty section still reserves one element so the bound range is

View file

@ -462,6 +462,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
private readonly SceneLightingUboBinding? _lightingUbo;
private readonly IWorldRenderRangeSource _ranges;
private readonly SkyPesFrameController? _skyPes;
private readonly Func<bool> _persistentDaylight;
private readonly HashSet<uint> _visibleCells = [];
private bool _visibleCellsValid;
@ -473,7 +474,8 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
EnvCellRenderer? environmentCells,
SceneLightingUboBinding? lightingUbo,
IWorldRenderRangeSource ranges,
SkyPesFrameController? skyPes)
SkyPesFrameController? skyPes,
Func<bool>? persistentDaylight = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
@ -483,6 +485,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
_lightingUbo = lightingUbo;
_ranges = ranges ?? throw new ArgumentNullException(nameof(ranges));
_skyPes = skyPes;
_persistentDaylight = persistentDaylight ?? (static () => false);
}
public void Prepare(
@ -500,7 +503,14 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
activeDayGroup,
camera.Position);
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
// LScape::set_landscape_lighting @0x005054D0 keeps the live sky/fog
// clock but, when PersistentAtDay is set, asks the active region for
// lighting at exactly 0.5 (noon). Do not pin WorldTime.DayFraction:
// clouds, celestial objects, fog, and scripts must keep advancing.
SkyKeyframe landscapeLighting = _persistentDaylight()
? _worldTime.SkyAtDayFraction(0.5f)
: foundation.Sky;
UpdateSunFromSky(landscapeLighting, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position);
_lighting.BuildPointLightSnapshot(