Move fallback and PView world drawing, shared alpha, particles, visibility, selection, and diagnostics behind focused frame owners. Make exceptional frames failure-atomic by restoring the shared GL baseline and preserving primary alpha failures through cleanup. Co-authored-by: Codex <codex@openai.com>
321 lines
10 KiB
C#
321 lines
10 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 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 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);
|
|
}
|
|
}
|
|
}
|