Retail never clips a particle to a portal view: each emitter's polys
join the ONE alpha list during its owner cell's far-to-near walk turn
(LScape::draw @0x00506330 iterates block_draw_list reversed; DrawBlock
@0x005A17C0 walks cells; ShouldDrawParticles @0x0050FE60 gates by cell
and distance), and occlusion is the depth test at FlushAlphaList
@0x0059D2E0 (its float is a COUNT threshold - 0f = flush all). The
1d2f2f73 architecture instead re-submitted particles once per
OutsideView slice under that slice's hardware clip slot, which cut
effects at aperture boundaries and drew nothing when no outside slice
was in view (the cathedral look-north disappearance).
Now: unattached emitters submit once per frame by owner-cell kind
(outdoor landcells in the landscape stage, interior EnvCells in the
final world scope - new UnattachedEmitterCellScope filter); cell,
shell-route, barrier-static, and late-stage owners submit their
per-slice cone-cull UNION once with clipSlot 0; and particles emit in
the stage matching their PARENT CELL - an interior dynamic whose
sphere straddles an exit-portal plane keeps its mesh in both stages
(#118) but its particles move to the final pass, so the interior
stage can no longer repaint over them (the aperture-band star cut).
Also lands the inert Change-2 primitives for the AP-236 retirement
(candle-behind-door): RetailAlphaQueue.FlushFartherThan drains only
the far prefix without resetting sources, plus the executor
passthrough and the conservative look-in threshold helper - nothing
calls them yet.
User-gated 2026-08-29 round 2 at the Sanctuary Cathedral: spell and
recall stars cover the whole room at every camera direction including
north; waterfall containment holds on retail's depth/seal mechanism;
adjacent-room particles/lights, walls, Holtburg, recall unregressed
(paperdoll remains pre-existing intermittent #443). Register: AP-236
filed for the remaining barrier-order divergence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
420 lines
14 KiB
C#
420 lines
14 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class RetailAlphaQueueTests
|
|
{
|
|
[Fact]
|
|
public void Flush_InterleavesRenderersInDescendingCyptOrder()
|
|
{
|
|
var log = new List<string>();
|
|
var objects = new RecordingSource("object", log);
|
|
var particles = new RecordingSource("particle", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(objects, token: 0, viewerDistance: 10f); // near crystal
|
|
queue.Submit(particles, token: 0, viewerDistance: 30f); // far smoke
|
|
queue.Submit(objects, token: 1, viewerDistance: 25f);
|
|
queue.Submit(particles, token: 1, viewerDistance: 20f);
|
|
queue.Submit(objects, token: 2, viewerDistance: 5f);
|
|
queue.EndFrame();
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"particle:0",
|
|
"object:1",
|
|
"particle:1",
|
|
"object:0",
|
|
"object:2",
|
|
},
|
|
log);
|
|
Assert.Equal(1, objects.ResetCount);
|
|
Assert.Equal(1, particles.ResetCount);
|
|
Assert.Equal(1, objects.PrepareCount);
|
|
Assert.Equal(1, particles.PrepareCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_EqualDistancePreservesSubmissionOrderAcrossRenderers()
|
|
{
|
|
var log = new List<string>();
|
|
var objects = new RecordingSource("object", log);
|
|
var particles = new RecordingSource("particle", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(objects, 7, 12f);
|
|
queue.Submit(particles, 4, 12f);
|
|
queue.Submit(objects, 8, 12f);
|
|
queue.EndFrame();
|
|
|
|
Assert.Equal(new[] { "object:7", "particle:4", "object:8" }, log);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_KeepsFrameOpenForPostDepthClearScope()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 1, 8f);
|
|
queue.Flush();
|
|
|
|
Assert.True(queue.IsCollecting);
|
|
Assert.Equal(0, queue.PendingCount);
|
|
queue.Submit(source, 2, 3f);
|
|
queue.EndFrame();
|
|
|
|
Assert.False(queue.IsCollecting);
|
|
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
|
|
Assert.Equal(2, source.ResetCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushFartherThan_DrainsOnlyTheFarPrefixAndRetainsNearerEntries()
|
|
{
|
|
// The pre-building barrier: retail's far→near walk means DrawBuilding's
|
|
// FlushAlphaList(0f) @0x0059F2A0 can only flush content from cells
|
|
// farther than that building; a nearer candle flame is not inserted
|
|
// yet and composites after the building at a later flush (AP-236).
|
|
var log = new List<string>();
|
|
var objects = new RecordingSource("object", log);
|
|
var particles = new RecordingSource("particle", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(particles, 0, 30f); // far waterfall
|
|
queue.Submit(objects, 0, 25f); // far translucent part
|
|
queue.Submit(particles, 1, 10f); // exactly at the building threshold
|
|
queue.Submit(particles, 2, 5f); // near candle flame — must be kept
|
|
queue.FlushFartherThan(10f);
|
|
|
|
Assert.True(queue.IsCollecting);
|
|
Assert.Equal(1, queue.PendingCount);
|
|
Assert.Equal(new[] { "particle:0", "object:0", "particle:1" }, log);
|
|
Assert.Equal(0, objects.ResetCount);
|
|
Assert.Equal(0, particles.ResetCount);
|
|
|
|
queue.EndFrame();
|
|
|
|
Assert.Equal(
|
|
new[] { "particle:0", "object:0", "particle:1", "particle:2" },
|
|
log);
|
|
Assert.Equal(1, objects.ResetCount);
|
|
Assert.Equal(1, particles.ResetCount);
|
|
Assert.Equal(2, particles.PrepareCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushFartherThan_WithNoFarEntries_LeavesTheQueueUntouched()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 1, 4f);
|
|
queue.FlushFartherThan(10f);
|
|
|
|
Assert.Empty(log);
|
|
Assert.Equal(1, queue.PendingCount);
|
|
Assert.Equal(0, source.PrepareCount);
|
|
Assert.Equal(0, source.ResetCount);
|
|
queue.EndFrame();
|
|
Assert.Equal(new[] { "alpha:1" }, log);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushFartherThan_DegenerateThreshold_DrainsAllWithoutResettingSources()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 1, 8f);
|
|
queue.Submit(source, 2, 2f);
|
|
queue.FlushFartherThan(0f);
|
|
|
|
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
|
|
Assert.Equal(0, queue.PendingCount);
|
|
Assert.Equal(0, source.ResetCount);
|
|
Assert.True(queue.IsCollecting);
|
|
queue.EndFrame();
|
|
Assert.Equal(1, source.ResetCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
|
|
{
|
|
var log = new List<string>();
|
|
var objects = new RecordingSource("object", log);
|
|
var particles = new RecordingSource("particle", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(objects, 0, 40f);
|
|
queue.Submit(objects, 1, 35f);
|
|
queue.Submit(particles, 0, 30f);
|
|
queue.Submit(objects, 2, 25f);
|
|
queue.EndFrame();
|
|
|
|
Assert.Equal(new[] { 2, 1 }, objects.BatchSizes);
|
|
Assert.Equal(new[] { 1 }, particles.BatchSizes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_LargeInputMatchesStableDescendingRetailOrder()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
var random = new Random(0xAC2013);
|
|
float[] distances = new float[4_096];
|
|
|
|
queue.BeginFrame();
|
|
for (int i = 0; i < distances.Length; i++)
|
|
{
|
|
distances[i] = i % 127 switch
|
|
{
|
|
0 => float.NaN,
|
|
1 => float.PositiveInfinity,
|
|
2 => -0f,
|
|
3 => -1f,
|
|
_ => random.Next(0, 64),
|
|
};
|
|
queue.Submit(source, i, distances[i]);
|
|
}
|
|
queue.EndFrame();
|
|
|
|
int[] expected = Enumerable.Range(0, distances.Length)
|
|
.OrderByDescending(i => NormalizeForReference(distances[i]))
|
|
.ToArray();
|
|
Assert.Equal(expected, source.PreparedTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_ExtremeFloatKeysMatchNormalizedRetailOrder()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
float[] distances =
|
|
[
|
|
1f,
|
|
float.BitIncrement(1f),
|
|
float.BitDecrement(1f),
|
|
float.Epsilon,
|
|
float.MaxValue,
|
|
0f,
|
|
-0f,
|
|
float.NaN,
|
|
float.PositiveInfinity,
|
|
float.NegativeInfinity,
|
|
-float.Epsilon,
|
|
1f,
|
|
];
|
|
|
|
queue.BeginFrame();
|
|
for (int i = 0; i < distances.Length; i++)
|
|
queue.Submit(source, i, distances[i]);
|
|
queue.EndFrame();
|
|
|
|
int[] expected = Enumerable.Range(0, distances.Length)
|
|
.OrderByDescending(i => NormalizeForReference(distances[i]))
|
|
.ToArray();
|
|
Assert.Equal(expected, source.PreparedTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetainedScratchConvergesAfterAOneScopeSpike()
|
|
{
|
|
const int budgetBytes = 128 * 1024;
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue(budgetBytes);
|
|
|
|
queue.BeginFrame();
|
|
for (int i = 0; i < 8_192; i++)
|
|
queue.Submit(source, i, i);
|
|
queue.EndFrame();
|
|
Assert.True(queue.RetainedScratchBytes > budgetBytes);
|
|
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
queue.BeginFrame();
|
|
queue.EndFrame();
|
|
}
|
|
|
|
Assert.True(queue.RetainedScratchBytes <= budgetBytes);
|
|
Assert.False(queue.IsCollecting);
|
|
Assert.Equal(0, queue.PendingCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_EqualDistanceOrderStartsFreshInEachFrameScope()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 1, 12f);
|
|
queue.Submit(source, 2, 12f);
|
|
queue.EndFrame();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 9, 12f);
|
|
queue.Submit(source, 8, 12f);
|
|
queue.EndFrame();
|
|
|
|
Assert.Equal(
|
|
["alpha:1", "alpha:2", "alpha:9", "alpha:8"],
|
|
log);
|
|
}
|
|
|
|
[Fact]
|
|
public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender()
|
|
{
|
|
var log = new List<string>();
|
|
var source = new RecordingSource("alpha", log);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 1, 12f);
|
|
queue.AbortFrame();
|
|
|
|
Assert.False(queue.IsCollecting);
|
|
Assert.Equal(0, queue.PendingCount);
|
|
Assert.Empty(log);
|
|
Assert.Equal(1, source.ResetCount);
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(source, 2, 12f);
|
|
queue.EndFrame();
|
|
|
|
Assert.Equal(["alpha:2"], log);
|
|
Assert.Equal(2, source.ResetCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EndFrame_DrawAndResetFailuresPreserveThePrimaryFailureAndClearTheFrame()
|
|
{
|
|
var drawSource = new FailureSource("draw failed", "first reset failed");
|
|
var secondSource = new FailureSource(null, null);
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(drawSource, 1, 20f);
|
|
queue.Submit(secondSource, 2, 10f);
|
|
|
|
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
|
|
|
|
Assert.Collection(
|
|
failure.InnerExceptions,
|
|
error => Assert.Equal("draw failed", error.Message),
|
|
error => Assert.Equal("first reset failed", error.Message));
|
|
Assert.Equal(1, drawSource.ResetCount);
|
|
Assert.Equal(1, secondSource.ResetCount);
|
|
Assert.Equal(0, queue.PendingCount);
|
|
Assert.False(queue.IsCollecting);
|
|
}
|
|
|
|
[Fact]
|
|
public void EndFrame_MultipleResetFailuresAttemptEverySourceAndClearTheFrame()
|
|
{
|
|
var first = new FailureSource(null, "first reset failed");
|
|
var second = new FailureSource(null, "second reset failed");
|
|
var queue = new RetailAlphaQueue();
|
|
|
|
queue.BeginFrame();
|
|
queue.Submit(first, 1, 20f);
|
|
queue.Submit(second, 2, 10f);
|
|
|
|
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
|
|
|
|
Assert.Collection(
|
|
failure.InnerExceptions,
|
|
error => Assert.Equal("first reset failed", error.Message),
|
|
error => Assert.Equal("second reset failed", error.Message));
|
|
Assert.Equal(1, first.ResetCount);
|
|
Assert.Equal(1, second.ResetCount);
|
|
Assert.Equal(0, queue.PendingCount);
|
|
Assert.False(queue.IsCollecting);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
|
|
{
|
|
// Retail CPhysicsPart::UpdateViewerDistance 0x0050E030 applies scale,
|
|
// orientation, and translation to gfxobj.sort_center before CYpt.
|
|
var model = Matrix4x4.CreateScale(2f)
|
|
* Matrix4x4.CreateRotationZ(MathF.PI / 2f)
|
|
* Matrix4x4.CreateTranslation(10f, 20f, 30f);
|
|
Vector3 localSortCenter = new(1f, 0f, 0f);
|
|
Vector3 camera = new(10f, 20f, 30f);
|
|
|
|
float cypt = RetailAlphaOrdering.ComputeViewerDistance(
|
|
localSortCenter,
|
|
model,
|
|
camera);
|
|
|
|
Assert.Equal(2f, cypt, precision: 5);
|
|
}
|
|
|
|
private static float NormalizeForReference(float value)
|
|
=> float.IsFinite(value) && value > 0f ? value : 0f;
|
|
|
|
private sealed class RecordingSource(string name, List<string> log) : IRetailAlphaDrawSource
|
|
{
|
|
public List<int> BatchSizes { get; } = new();
|
|
public int ResetCount { get; private set; }
|
|
public int PrepareCount { get; private set; }
|
|
public IReadOnlyList<int> PreparedTokens => _prepared;
|
|
private int[] _prepared = [];
|
|
|
|
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
|
|
{
|
|
PrepareCount++;
|
|
_prepared = tokens.ToArray();
|
|
}
|
|
|
|
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
|
|
{
|
|
BatchSizes.Add(drawCount);
|
|
for (int i = 0; i < drawCount; i++)
|
|
log.Add($"{name}:{_prepared[firstPreparedDraw + i]}");
|
|
}
|
|
|
|
public void ResetAlphaSubmissions() => ResetCount++;
|
|
}
|
|
|
|
private sealed class FailureSource(
|
|
string? drawFailure,
|
|
string? resetFailure) : IRetailAlphaDrawSource
|
|
{
|
|
public int ResetCount { get; private set; }
|
|
|
|
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
|
|
{
|
|
}
|
|
|
|
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
|
|
{
|
|
if (drawFailure is not null)
|
|
throw new InvalidOperationException(drawFailure);
|
|
}
|
|
|
|
public void ResetAlphaSubmissions()
|
|
{
|
|
ResetCount++;
|
|
if (resetFailure is not null)
|
|
throw new InvalidOperationException(resetFailure);
|
|
}
|
|
}
|
|
}
|