feat(rendering): establish current-path scene referee

Capture deterministic, landblock-aware fingerprints from the accepted partition only during lifecycle automation. This gives the F/G shadow scene an independent oracle without changing normal draw decisions or adding disabled-path per-entity cost.
This commit is contained in:
Erik 2026-07-24 21:13:11 +02:00
parent 5ecaa5612d
commit b2b67341ac
9 changed files with 845 additions and 5 deletions

View file

@ -12,6 +12,20 @@
visibility, clipping, draw order, selection, lighting, animation, or visibility, clipping, draw order, selection, lighting, animation, or
retail-faithful gameplay retail-faithful gameplay
## 0. Execution ledger
| Unit | State | Evidence / rollback |
|---|---|---|
| F0a — partition-input referee | implemented; Release gate green | Diagnostic-only `CurrentRenderSceneOracle`; 3,685 App tests / 3 skips and 8,169 complete-solution tests / 5 skips. No production draw decision changed. |
| F0b — survivor/dispatcher referee | active | Extends the same current-path oracle through PView survivor and dispatcher classification boundaries. |
| F1F5 | pending | No render-scene storage or shadow projection exists yet. |
| G0G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0a is
non-drawing diagnostic infrastructure and therefore is not a visual-cutover
rollback unit. Each later commit that changes the production draw source is
listed in section 10 before it is offered for a connected gate.
## 1. Decision record ## 1. Decision record
### 1.1 Why this work has a separate approval gate ### 1.1 Why this work has a separate approval gate

View file

@ -1,6 +1,7 @@
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings; using AcDream.App.Settings;
using AcDream.App.Update; using AcDream.App.Update;
@ -368,6 +369,11 @@ internal sealed class FrameRootCompositionPhase
live.SkyRenderer, live.SkyRenderer,
content.ParticleSystem, content.ParticleSystem,
live.ParticleRenderer); live.ParticleRenderer);
CurrentRenderSceneOracle? currentRenderSceneOracle =
interaction.RetainedUi?.Screenshots is not null
&& d.Options.AutomationArtifactDirectory is not null
? new CurrentRenderSceneOracle()
: null;
var worldSceneRenderer = new WorldSceneRenderer( var worldSceneRenderer = new WorldSceneRenderer(
renderFrameResources, renderFrameResources,
renderLoginState, renderLoginState,
@ -378,7 +384,7 @@ internal sealed class FrameRootCompositionPhase
d.RetailAlphaQueue, d.RetailAlphaQueue,
d.ParticleVisibility, d.ParticleVisibility,
new WorldScenePViewRenderer( new WorldScenePViewRenderer(
new RetailPViewRenderer(), new RetailPViewRenderer(currentRenderSceneOracle),
retailPViewPassExecutor, retailPViewPassExecutor,
retailPViewPassExecutor), retailPViewPassExecutor),
retailPViewCells, retailPViewCells,
@ -410,7 +416,8 @@ internal sealed class FrameRootCompositionPhase
live.DrawDispatcher, live.DrawDispatcher,
d.FrameProfiler, d.FrameProfiler,
content.Dats, content.Dats,
foundation.Residency); foundation.Residency,
currentRenderSceneOracle);
lifecycleAutomation = lifecycleAutomation =
new WorldLifecycleAutomationController( new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot, () => session.WorldReveal.Snapshot,

View file

@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.UI.Testing; using AcDream.App.UI.Testing;
@ -38,6 +39,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
int TotalLandblocks, int TotalLandblocks,
int LiveEntities, int LiveEntities,
int MaterializedLiveEntities, int MaterializedLiveEntities,
CurrentRenderSceneOracleSnapshot RenderSceneOracle,
int PendingLiveTeardowns, int PendingLiveTeardowns,
int PendingLandblockRetirements, int PendingLandblockRetirements,
int ParticleEmitters, int ParticleEmitters,

View file

@ -1,4 +1,5 @@
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
@ -38,6 +39,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly FrameProfiler _frameProfiler; private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency; private readonly ResidencyManager _residency;
private readonly ICurrentRenderSceneOracleSnapshotSource?
_renderSceneOracle;
public WorldLifecycleResourceSnapshotSource( public WorldLifecycleResourceSnapshotSource(
GpuWorldState world, GpuWorldState world,
@ -55,7 +58,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
FrameProfiler frameProfiler, FrameProfiler frameProfiler,
IDatReaderWriter dats, IDatReaderWriter dats,
ResidencyManager residency) ResidencyManager residency,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null)
{ {
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations _animations = animations
@ -82,6 +86,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_residency = residency _residency = residency
?? throw new ArgumentNullException(nameof(residency)); ?? throw new ArgumentNullException(nameof(residency));
_renderSceneOracle = renderSceneOracle;
} }
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome) public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@ -122,6 +127,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
TotalLandblocks: outcome.World.TotalLandblocks, TotalLandblocks: outcome.World.TotalLandblocks,
LiveEntities: _liveEntities.Count, LiveEntities: _liveEntities.Count,
MaterializedLiveEntities: _liveEntities.MaterializedCount, MaterializedLiveEntities: _liveEntities.MaterializedCount,
RenderSceneOracle: _renderSceneOracle?.Snapshot
?? CurrentRenderSceneOracleSnapshot.Disabled,
PendingLiveTeardowns: _liveEntities.PendingTeardownCount, PendingLiveTeardowns: _liveEntities.PendingTeardownCount,
PendingLandblockRetirements: _streaming.PendingRetirementCount, PendingLandblockRetirements: _streaming.PendingRetirementCount,
ParticleEmitters: _particles.ActiveEmitterCount, ParticleEmitters: _particles.ActiveEmitterCount,

View file

@ -30,6 +30,27 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public static class InteriorEntityPartition public static class InteriorEntityPartition
{ {
internal enum ProjectionClass : byte
{
OutdoorStatic,
CellStatic,
Dynamic,
}
internal interface IObserver
{
void BeginFrame();
void Observe(
uint landblockId,
WorldEntity entity,
ProjectionClass projectionClass);
void Complete(Result result);
void AbortFrame();
}
public sealed class Result public sealed class Result
{ {
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new(); public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
@ -143,6 +164,79 @@ public static class InteriorEntityPartition
result.PruneEmptyCellBuckets(); result.PruneEmptyCellBuckets();
} }
/// <summary>
/// Current-path referee overload. The observer sees the exact branch that
/// populated each accepted partition entry, including its owning
/// landblock. A null observer is the production fast path and performs no
/// diagnostic allocation or hashing.
/// </summary>
internal static void Partition(
Result result,
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
IObserver? observer)
{
if (observer is null)
{
Partition(result, visibleCells, landblockEntries);
return;
}
observer.BeginFrame();
try
{
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
foreach (var e in entry.Entities)
{
if (e.MeshRefs.Count == 0) continue;
// Retail contract: every server-spawned entity is a
// DYNAMIC and draws in the last pass, regardless of cell.
if (e.ServerGuid != 0)
{
result.Dynamics.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.Dynamic);
}
else if (e.ParentCellId is uint cell && IsIndoorCellId(cell))
{
if (!visibleCells.Contains(cell))
continue;
if (!result.ByCell.TryGetValue(cell, out var list))
result.ByCell[cell] = list = new List<WorldEntity>();
list.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.CellStatic);
}
else
{
result.OutdoorStatic.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.OutdoorStatic);
}
}
}
result.PruneEmptyCellBuckets();
observer.Complete(result);
}
catch
{
observer.AbortFrame();
throw;
}
}
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the /// <summary>Shared indoor classification — keep DrawDynamicsLast, the
/// outside-stage assignment (#118), and the partition in lockstep.</summary> /// outside-stage assignment (#118), and the partition in lockstep.</summary>
public static bool IsIndoorCellId(uint cellId) public static bool IsIndoorCellId(uint cellId)

View file

@ -12,6 +12,7 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed class RetailPViewRenderer public sealed class RetailPViewRenderer
{ {
private readonly InteriorEntityPartition.IObserver? _partitionObserver;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new(); private readonly ViewconeCuller _viewconeScratch = new();
@ -63,6 +64,16 @@ public sealed class RetailPViewRenderer
private readonly HashSet<uint> _drawableCellsScratch = new(); private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly RetailPViewScratchRetention _scratchRetention = new(); private readonly RetailPViewScratchRetention _scratchRetention = new();
public RetailPViewRenderer()
{
}
internal RetailPViewRenderer(
InteriorEntityPartition.IObserver? partitionObserver)
{
_partitionObserver = partitionObserver;
}
// T2 (BR-4): retail has NO distance constant on the flood-admission chain // T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the // GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -156,7 +167,11 @@ public sealed class RetailPViewRenderer
passes.PrepareCellBatches(ctx, prepareCells); passes.PrepareCellBatches(ctx, prepareCells);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); InteriorEntityPartition.Partition(
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver);
var partition = _partitionResult; var partition = _partitionResult;
RetailPViewFrameResult result = _frameResultScratch.Reset( RetailPViewFrameResult result = _frameResultScratch.Reset(
pvFrame, pvFrame,

View file

@ -0,0 +1,389 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
internal readonly record struct RenderSceneHash128(ulong Low, ulong High)
{
public static RenderSceneHash128 Empty { get; } = new(0, 0);
public override string ToString() => $"{High:X16}{Low:X16}";
}
internal readonly record struct CurrentRenderProjectionFingerprint(
InteriorEntityPartition.ProjectionClass ProjectionClass,
uint LandblockId,
uint EntityId,
uint ServerGuid,
uint SourceId,
uint ParentCellId,
uint EffectCellId,
uint BuildingShellAnchorCellId,
uint Flags,
int MeshCount,
RenderSceneHash128 Transform,
RenderSceneHash128 Geometry,
RenderSceneHash128 Appearance);
internal readonly record struct CurrentRenderSceneOracleSnapshot(
bool Enabled,
ulong CompletedFrameSequence,
int AbortedFrames,
int ProjectionCount,
int OutdoorStaticCount,
int CellStaticCount,
int DynamicCount,
int CellBucketCount,
RenderSceneHash128 Digest)
{
public static CurrentRenderSceneOracleSnapshot Disabled { get; } = new(
Enabled: false,
CompletedFrameSequence: 0,
AbortedFrames: 0,
ProjectionCount: 0,
OutdoorStaticCount: 0,
CellStaticCount: 0,
DynamicCount: 0,
CellBucketCount: 0,
Digest: RenderSceneHash128.Empty);
}
internal interface ICurrentRenderSceneOracleSnapshotSource
{
CurrentRenderSceneOracleSnapshot Snapshot { get; }
}
/// <summary>
/// Deterministic fingerprint of the already-accepted
/// <see cref="InteriorEntityPartition"/> output. This is the Slice-F referee:
/// it records current behavior before a shadow render scene exists, and never
/// makes a draw or visibility decision itself.
///
/// Collection storage is retained across frames. Production composition does
/// not construct this owner unless lifecycle automation is enabled, so normal
/// rendering pays no hashing, sorting, or allocation cost.
/// </summary>
internal sealed class CurrentRenderSceneOracle :
InteriorEntityPartition.IObserver,
ICurrentRenderSceneOracleSnapshotSource
{
private readonly List<CurrentRenderProjectionFingerprint> _projections = [];
private ulong _frameSequence;
private int _abortedFrames;
private int _outdoorStaticCount;
private int _cellStaticCount;
private int _dynamicCount;
private bool _frameOpen;
public CurrentRenderSceneOracleSnapshot Snapshot { get; private set; } =
CurrentRenderSceneOracleSnapshot.Disabled with { Enabled = true };
internal IReadOnlyList<CurrentRenderProjectionFingerprint> Projections =>
_projections;
public void BeginFrame()
{
if (_frameOpen)
{
throw new InvalidOperationException(
"The current render-scene oracle cannot begin a second frame before completing or aborting the first.");
}
_frameOpen = true;
_projections.Clear();
_outdoorStaticCount = 0;
_cellStaticCount = 0;
_dynamicCount = 0;
}
public void Observe(
uint landblockId,
WorldEntity entity,
InteriorEntityPartition.ProjectionClass projectionClass)
{
if (!_frameOpen)
{
throw new InvalidOperationException(
"The current render-scene oracle received a projection outside an open frame.");
}
ArgumentNullException.ThrowIfNull(entity);
switch (projectionClass)
{
case InteriorEntityPartition.ProjectionClass.OutdoorStatic:
_outdoorStaticCount++;
break;
case InteriorEntityPartition.ProjectionClass.CellStatic:
_cellStaticCount++;
break;
case InteriorEntityPartition.ProjectionClass.Dynamic:
_dynamicCount++;
break;
default:
throw new ArgumentOutOfRangeException(
nameof(projectionClass),
projectionClass,
"Unknown current render projection class.");
}
_projections.Add(CreateFingerprint(
landblockId,
entity,
projectionClass));
}
public void Complete(InteriorEntityPartition.Result result)
{
ArgumentNullException.ThrowIfNull(result);
if (!_frameOpen)
{
throw new InvalidOperationException(
"The current render-scene oracle cannot complete without an open frame.");
}
int cellStaticCount = 0;
foreach (List<WorldEntity> entities in result.ByCell.Values)
cellStaticCount = checked(cellStaticCount + entities.Count);
if (result.OutdoorStatic.Count != _outdoorStaticCount
|| cellStaticCount != _cellStaticCount
|| result.Dynamics.Count != _dynamicCount
|| _projections.Count
!= checked(_outdoorStaticCount + _cellStaticCount + _dynamicCount))
{
throw new InvalidOperationException(
"The current render-scene oracle diverged from the partition it observed.");
}
_projections.Sort(CurrentRenderProjectionFingerprintComparer.Instance);
StableRenderHash128 aggregate = StableRenderHash128.Create();
aggregate.Add(_projections.Count);
foreach (CurrentRenderProjectionFingerprint projection in _projections)
AddProjection(ref aggregate, in projection);
_frameOpen = false;
Snapshot = new CurrentRenderSceneOracleSnapshot(
Enabled: true,
CompletedFrameSequence: checked(++_frameSequence),
AbortedFrames: _abortedFrames,
ProjectionCount: _projections.Count,
OutdoorStaticCount: _outdoorStaticCount,
CellStaticCount: _cellStaticCount,
DynamicCount: _dynamicCount,
CellBucketCount: result.ByCell.Count,
Digest: aggregate.Finish());
}
public void AbortFrame()
{
if (!_frameOpen)
return;
_frameOpen = false;
_projections.Clear();
_outdoorStaticCount = 0;
_cellStaticCount = 0;
_dynamicCount = 0;
_abortedFrames = checked(_abortedFrames + 1);
Snapshot = Snapshot with { AbortedFrames = _abortedFrames };
}
private static CurrentRenderProjectionFingerprint CreateFingerprint(
uint landblockId,
WorldEntity entity,
InteriorEntityPartition.ProjectionClass projectionClass)
{
StableRenderHash128 transform = StableRenderHash128.Create();
transform.Add(entity.Position);
transform.Add(entity.Rotation);
transform.Add(entity.Scale);
StableRenderHash128 geometry = StableRenderHash128.Create();
geometry.Add(entity.MeshRefs.Count);
foreach (MeshRef mesh in entity.MeshRefs)
{
geometry.Add(mesh.GfxObjId);
geometry.Add(mesh.PartTransform);
}
StableRenderHash128 appearance = StableRenderHash128.Create();
if (entity.PaletteOverride is { } palette)
{
appearance.Add(true);
appearance.Add(palette.BasePaletteId);
appearance.Add(palette.SubPalettes.Count);
foreach (PaletteOverride.SubPaletteRange range in palette.SubPalettes)
{
appearance.Add(range.SubPaletteId);
appearance.Add(range.Offset);
appearance.Add(range.Length);
}
}
else
{
appearance.Add(false);
}
appearance.Add(entity.PartOverrides.Count);
foreach (PartOverride part in entity.PartOverrides)
{
appearance.Add(part.PartIndex);
appearance.Add(part.GfxObjId);
}
appearance.Add(entity.HiddenPartsMask);
uint flags = 0;
if (entity.IsDrawVisible)
flags |= 1u << 0;
if (entity.IsAncestorDrawVisible)
flags |= 1u << 1;
if (entity.IsBuildingShell)
flags |= 1u << 2;
if (entity.ParentCellId.HasValue)
flags |= 1u << 3;
if (entity.EffectCellId.HasValue)
flags |= 1u << 4;
if (entity.BuildingShellAnchorCellId.HasValue)
flags |= 1u << 5;
return new CurrentRenderProjectionFingerprint(
ProjectionClass: projectionClass,
LandblockId: landblockId,
EntityId: entity.Id,
ServerGuid: entity.ServerGuid,
SourceId: entity.SourceGfxObjOrSetupId,
ParentCellId: entity.ParentCellId ?? 0,
EffectCellId: entity.EffectCellId ?? 0,
BuildingShellAnchorCellId: entity.BuildingShellAnchorCellId ?? 0,
Flags: flags,
MeshCount: entity.MeshRefs.Count,
Transform: transform.Finish(),
Geometry: geometry.Finish(),
Appearance: appearance.Finish());
}
private static void AddProjection(
ref StableRenderHash128 hash,
in CurrentRenderProjectionFingerprint projection)
{
hash.Add((byte)projection.ProjectionClass);
hash.Add(projection.LandblockId);
hash.Add(projection.EntityId);
hash.Add(projection.ServerGuid);
hash.Add(projection.SourceId);
hash.Add(projection.ParentCellId);
hash.Add(projection.EffectCellId);
hash.Add(projection.BuildingShellAnchorCellId);
hash.Add(projection.Flags);
hash.Add(projection.MeshCount);
hash.Add(projection.Transform.Low);
hash.Add(projection.Transform.High);
hash.Add(projection.Geometry.Low);
hash.Add(projection.Geometry.High);
hash.Add(projection.Appearance.Low);
hash.Add(projection.Appearance.High);
}
private sealed class CurrentRenderProjectionFingerprintComparer :
IComparer<CurrentRenderProjectionFingerprint>
{
public static CurrentRenderProjectionFingerprintComparer Instance { get; } =
new();
public int Compare(
CurrentRenderProjectionFingerprint x,
CurrentRenderProjectionFingerprint y)
{
int value = x.ProjectionClass.CompareTo(y.ProjectionClass);
if (value != 0) return value;
value = x.LandblockId.CompareTo(y.LandblockId);
if (value != 0) return value;
value = x.ParentCellId.CompareTo(y.ParentCellId);
if (value != 0) return value;
value = x.EntityId.CompareTo(y.EntityId);
if (value != 0) return value;
value = x.ServerGuid.CompareTo(y.ServerGuid);
if (value != 0) return value;
value = x.SourceId.CompareTo(y.SourceId);
if (value != 0) return value;
value = x.EffectCellId.CompareTo(y.EffectCellId);
if (value != 0) return value;
value = x.BuildingShellAnchorCellId.CompareTo(
y.BuildingShellAnchorCellId);
if (value != 0) return value;
value = x.Flags.CompareTo(y.Flags);
if (value != 0) return value;
value = x.MeshCount.CompareTo(y.MeshCount);
if (value != 0) return value;
value = x.Transform.High.CompareTo(y.Transform.High);
if (value != 0) return value;
value = x.Transform.Low.CompareTo(y.Transform.Low);
if (value != 0) return value;
value = x.Geometry.High.CompareTo(y.Geometry.High);
if (value != 0) return value;
value = x.Geometry.Low.CompareTo(y.Geometry.Low);
if (value != 0) return value;
value = x.Appearance.High.CompareTo(y.Appearance.High);
if (value != 0) return value;
return x.Appearance.Low.CompareTo(y.Appearance.Low);
}
}
}
internal struct StableRenderHash128
{
private const ulong LowOffset = 14695981039346656037UL;
private const ulong LowPrime = 1099511628211UL;
private const ulong HighOffset = 7809847782465536322UL;
private const ulong HighPrime = 14029467366897019727UL;
private ulong _low;
private ulong _high;
public static StableRenderHash128 Create() => new()
{
_low = LowOffset,
_high = HighOffset,
};
public readonly RenderSceneHash128 Finish() => new(_low, _high);
public void Add(bool value) => Add(value ? (byte)1 : (byte)0);
public void Add(byte value) => Add((ulong)value);
public void Add(int value) => Add(unchecked((uint)value));
public void Add(uint value) => Add((ulong)value);
public void Add(float value) => Add(BitConverter.SingleToUInt32Bits(value));
public void Add(ulong value)
{
_low = unchecked((_low ^ value) * LowPrime);
ulong folded = value ^ BitOperations.RotateLeft(value, 29);
_high = unchecked((_high ^ folded) * HighPrime);
}
public void Add(Vector3 value)
{
Add(value.X);
Add(value.Y);
Add(value.Z);
}
public void Add(Quaternion value)
{
Add(value.X);
Add(value.Y);
Add(value.Z);
Add(value.W);
}
public void Add(Matrix4x4 value)
{
Add(value.M11); Add(value.M12); Add(value.M13); Add(value.M14);
Add(value.M21); Add(value.M22); Add(value.M23); Add(value.M24);
Add(value.M31); Add(value.M32); Add(value.M33); Add(value.M34);
Add(value.M41); Add(value.M42); Add(value.M43); Add(value.M44);
}
}

View file

@ -2,6 +2,7 @@ using System.Text.Json;
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.UI.Testing; using AcDream.App.UI.Testing;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
@ -114,6 +115,18 @@ public sealed class WorldLifecycleAutomationControllerTests
{ {
LoadedLandblocks = 1, LoadedLandblocks = 1,
WorldEntities = 42, WorldEntities = 42,
RenderSceneOracle = new CurrentRenderSceneOracleSnapshot(
Enabled: true,
CompletedFrameSequence: 19,
AbortedFrames: 1,
ProjectionCount: 73,
OutdoorStaticCount: 41,
CellStaticCount: 20,
DynamicCount: 12,
CellBucketCount: 4,
Digest: new RenderSceneHash128(
Low: 0x0123456789ABCDEF,
High: 0xFEDCBA9876543210)),
TrackedGpuBytes = 1234, TrackedGpuBytes = 1234,
Residency = new ResidencySnapshot( Residency = new ResidencySnapshot(
[ [
@ -166,6 +179,24 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation") Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation")
.GetProperty("screenshotCaptured").GetBoolean()); .GetProperty("screenshotCaptured").GetBoolean());
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32()); Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
JsonElement renderSceneOracle = json.RootElement
.GetProperty("resources")
.GetProperty("renderSceneOracle");
Assert.True(renderSceneOracle.GetProperty("enabled").GetBoolean());
Assert.Equal(
19,
renderSceneOracle.GetProperty("completedFrameSequence").GetInt64());
Assert.Equal(
73,
renderSceneOracle.GetProperty("projectionCount").GetInt32());
Assert.Equal(
0x0123456789ABCDEFuL,
renderSceneOracle.GetProperty("digest").GetProperty("low")
.GetUInt64());
Assert.Equal(
0xFEDCBA9876543210uL,
renderSceneOracle.GetProperty("digest").GetProperty("high")
.GetUInt64());
JsonElement residency = json.RootElement JsonElement residency = json.RootElement
.GetProperty("resources") .GetProperty("resources")
.GetProperty("residency"); .GetProperty("residency");
@ -304,7 +335,8 @@ public sealed class WorldLifecycleAutomationControllerTests
directory); directory);
private static WorldLifecycleResourceSnapshot EmptyResources() => new( private static WorldLifecycleResourceSnapshot EmptyResources() => new(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
default, default, 0d, 0d, null); default, default, 0d, 0d, null);

View file

@ -0,0 +1,280 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class CurrentRenderSceneOracleTests
{
private const uint LandblockA = 0xA9B4FFFF;
private const uint LandblockB = 0xA9B5FFFF;
private const uint CellA = 0xA9B40170;
private const uint CellB = 0xA9B50170;
[Fact]
public void DigestIsStableAcrossLandblockEnumerationOrder()
{
WorldEntity outdoorA = Entity(id: 7, serverGuid: 0, parentCell: null);
WorldEntity cellStatic = Entity(id: 8, serverGuid: 0, parentCell: CellA);
WorldEntity live = Entity(
id: 1_000_001,
serverGuid: 0x8000_0001,
parentCell: CellA);
// Static IDs are landblock-local. The same id in another landblock is
// a distinct projection and must survive the referee sort.
WorldEntity outdoorB = Entity(id: 7, serverGuid: 0, parentCell: null);
var firstEntries = new[]
{
Entry(LandblockA, outdoorA, cellStatic, live),
Entry(LandblockB, outdoorB),
};
var secondEntries = new[]
{
Entry(LandblockB, outdoorB),
Entry(LandblockA, outdoorA, cellStatic, live),
};
var visible = new HashSet<uint> { CellA };
var result = new InteriorEntityPartition.Result();
var oracle = new CurrentRenderSceneOracle();
InteriorEntityPartition.Partition(
result,
visible,
firstEntries,
oracle);
CurrentRenderSceneOracleSnapshot first = oracle.Snapshot;
InteriorEntityPartition.Partition(
result,
visible,
secondEntries,
oracle);
CurrentRenderSceneOracleSnapshot second = oracle.Snapshot;
Assert.True(first.Enabled);
Assert.Equal(4, first.ProjectionCount);
Assert.Equal(2, first.OutdoorStaticCount);
Assert.Equal(1, first.CellStaticCount);
Assert.Equal(1, first.DynamicCount);
Assert.Equal(1, first.CellBucketCount);
Assert.Equal(first.Digest, second.Digest);
Assert.Equal(first.CompletedFrameSequence + 1, second.CompletedFrameSequence);
Assert.Equal(2, oracle.Projections.Count(
projection => projection.EntityId == 7));
Assert.Contains(
oracle.Projections,
projection => projection.EntityId == 7
&& projection.LandblockId == LandblockA);
Assert.Contains(
oracle.Projections,
projection => projection.EntityId == 7
&& projection.LandblockId == LandblockB);
}
[Fact]
public void ObservedPartitionMatchesUninstrumentedProductionPath()
{
WorldEntity outdoor = Entity(id: 10, serverGuid: 0, parentCell: null);
WorldEntity cellStatic = Entity(id: 11, serverGuid: 0, parentCell: CellA);
WorldEntity dynamic = Entity(
id: 1_000_011,
serverGuid: 0x8000_0011,
parentCell: CellB);
WorldEntity hiddenStatic = Entity(
id: 12,
serverGuid: 0,
parentCell: CellB);
var entries = new[]
{
Entry(LandblockA, outdoor, cellStatic),
Entry(LandblockB, dynamic, hiddenStatic),
};
var visible = new HashSet<uint> { CellA };
var production = new InteriorEntityPartition.Result();
var observed = new InteriorEntityPartition.Result();
var oracle = new CurrentRenderSceneOracle();
InteriorEntityPartition.Partition(production, visible, entries);
InteriorEntityPartition.Partition(observed, visible, entries, oracle);
Assert.Equal(production.ByCell.Keys, observed.ByCell.Keys);
foreach (uint cell in production.ByCell.Keys)
Assert.Equal(production.ByCell[cell], observed.ByCell[cell]);
Assert.Equal(production.OutdoorStatic, observed.OutdoorStatic);
Assert.Equal(production.Dynamics, observed.Dynamics);
}
[Fact]
public void FingerprintsNameTransformGeometryAppearanceAndFlagChanges()
{
var visible = new HashSet<uint> { CellA, CellB };
CurrentRenderProjectionFingerprint baseline = CaptureSingle(
Entity(id: 20, serverGuid: 0, parentCell: CellA),
LandblockA,
visible);
CurrentRenderProjectionFingerprint transform = CaptureSingle(
Entity(
id: 20,
serverGuid: 0,
parentCell: CellA,
position: new Vector3(1, 2, 3)),
LandblockA,
visible);
CurrentRenderProjectionFingerprint geometry = CaptureSingle(
Entity(
id: 20,
serverGuid: 0,
parentCell: CellA,
meshId: 0x0100_2222),
LandblockA,
visible);
CurrentRenderProjectionFingerprint appearance = CaptureSingle(
Entity(
id: 20,
serverGuid: 0,
parentCell: CellA,
palette: new PaletteOverride(
0x0400_0001,
[new PaletteOverride.SubPaletteRange(0x0400_0002, 3, 4)])),
LandblockA,
visible);
WorldEntity hidden = Entity(id: 20, serverGuid: 0, parentCell: CellA);
hidden.IsDrawVisible = false;
CurrentRenderProjectionFingerprint flags = CaptureSingle(
hidden,
LandblockA,
visible);
CurrentRenderProjectionFingerprint cell = CaptureSingle(
Entity(id: 20, serverGuid: 0, parentCell: CellB),
LandblockB,
visible);
Assert.NotEqual(baseline.Transform, transform.Transform);
Assert.Equal(baseline.Geometry, transform.Geometry);
Assert.Equal(baseline.Appearance, transform.Appearance);
Assert.NotEqual(baseline.Geometry, geometry.Geometry);
Assert.Equal(baseline.Transform, geometry.Transform);
Assert.NotEqual(baseline.Appearance, appearance.Appearance);
Assert.NotEqual(baseline.Flags, flags.Flags);
Assert.NotEqual(baseline.ParentCellId, cell.ParentCellId);
Assert.NotEqual(baseline.LandblockId, cell.LandblockId);
}
[Fact]
public void NonFloodedStaticIsAbsentButNonFloodedDynamicRemains()
{
WorldEntity hiddenStatic = Entity(
id: 30,
serverGuid: 0,
parentCell: CellB);
WorldEntity hiddenDynamic = Entity(
id: 1_000_030,
serverGuid: 0x8000_0030,
parentCell: CellB);
var oracle = new CurrentRenderSceneOracle();
var result = new InteriorEntityPartition.Result();
InteriorEntityPartition.Partition(
result,
new HashSet<uint> { CellA },
new[] { Entry(LandblockB, hiddenStatic, hiddenDynamic) },
oracle);
Assert.Equal(1, oracle.Snapshot.ProjectionCount);
Assert.Equal(0, oracle.Snapshot.CellStaticCount);
Assert.Equal(1, oracle.Snapshot.DynamicCount);
CurrentRenderProjectionFingerprint fingerprint =
Assert.Single(oracle.Projections);
Assert.Equal(hiddenDynamic.Id, fingerprint.EntityId);
Assert.Equal(
InteriorEntityPartition.ProjectionClass.Dynamic,
fingerprint.ProjectionClass);
}
[Fact]
public void AbortPreservesLastCompleteDigestAndRecordsFailure()
{
var oracle = new CurrentRenderSceneOracle();
var result = new InteriorEntityPartition.Result();
WorldEntity entity = Entity(id: 40, serverGuid: 0, parentCell: null);
InteriorEntityPartition.Partition(
result,
[],
new[] { Entry(LandblockA, entity) },
oracle);
CurrentRenderSceneOracleSnapshot complete = oracle.Snapshot;
oracle.BeginFrame();
oracle.Observe(
LandblockA,
entity,
InteriorEntityPartition.ProjectionClass.OutdoorStatic);
oracle.AbortFrame();
Assert.Equal(complete.Digest, oracle.Snapshot.Digest);
Assert.Equal(
complete.CompletedFrameSequence,
oracle.Snapshot.CompletedFrameSequence);
Assert.Equal(complete.AbortedFrames + 1, oracle.Snapshot.AbortedFrames);
Assert.Empty(oracle.Projections);
}
private static CurrentRenderProjectionFingerprint CaptureSingle(
WorldEntity entity,
uint landblockId,
HashSet<uint> visible)
{
var oracle = new CurrentRenderSceneOracle();
var result = new InteriorEntityPartition.Result();
InteriorEntityPartition.Partition(
result,
visible,
new[] { Entry(landblockId, entity) },
oracle);
return Assert.Single(oracle.Projections);
}
private static WorldEntity Entity(
uint id,
uint serverGuid,
uint? parentCell,
Vector3? position = null,
uint meshId = 0x0100_0001,
PaletteOverride? palette = null) =>
new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = meshId,
Position = position ?? Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs =
[
new MeshRef(
meshId,
Matrix4x4.CreateTranslation(0.25f, 0.5f, 0.75f)),
],
ParentCellId = parentCell,
PaletteOverride = palette,
};
private static (
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)
Entry(uint landblockId, params WorldEntity[] entities) =>
(
landblockId,
Vector3.Zero,
Vector3.One,
entities,
null);
}