test: replace render source freezes
This commit is contained in:
parent
c31a9ac411
commit
5a33369074
7 changed files with 425 additions and 345 deletions
109
tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs
Normal file
109
tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
|
||||
namespace AcDream.App.Tests.Architecture;
|
||||
|
||||
internal readonly record struct CompiledCall(int Offset, MethodBase Target);
|
||||
|
||||
/// <summary>
|
||||
/// Reads compiled call/new-object edges from a method body. Architecture tests
|
||||
/// use this when the contract is an ownership or ordering edge that cannot be
|
||||
/// exercised through a public result, avoiding formatting- and comment-sensitive
|
||||
/// source-string assertions.
|
||||
/// </summary>
|
||||
internal static class CompiledCallGraph
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<short, OpCode> OpCodesByValue =
|
||||
typeof(OpCodes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(field => field.FieldType == typeof(OpCode))
|
||||
.Select(field => (OpCode)field.GetValue(null)!)
|
||||
.ToDictionary(opCode => opCode.Value);
|
||||
|
||||
public static IReadOnlyList<CompiledCall> Read(MethodBase method)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
byte[] il = method.GetMethodBody()?.GetILAsByteArray()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{method.DeclaringType?.FullName}.{method.Name} has no compiled body.");
|
||||
Type[]? declaringArguments = method.DeclaringType?.IsGenericType == true
|
||||
? method.DeclaringType.GetGenericArguments()
|
||||
: null;
|
||||
Type[]? methodArguments = method.IsGenericMethod
|
||||
? method.GetGenericArguments()
|
||||
: null;
|
||||
var calls = new List<CompiledCall>();
|
||||
|
||||
for (int cursor = 0; cursor < il.Length;)
|
||||
{
|
||||
int instructionOffset = cursor;
|
||||
OpCode opCode = ReadOpCode(il, ref cursor);
|
||||
if (opCode.OperandType == OperandType.InlineMethod)
|
||||
{
|
||||
int token = BitConverter.ToInt32(il, cursor);
|
||||
MethodBase? target = method.Module.ResolveMethod(
|
||||
token,
|
||||
declaringArguments,
|
||||
methodArguments);
|
||||
if (target is not null && opCode is { Value: var value }
|
||||
&& value is 0x28 or 0x6F or 0x73)
|
||||
{
|
||||
calls.Add(new CompiledCall(instructionOffset, target));
|
||||
}
|
||||
}
|
||||
|
||||
cursor += OperandSize(opCode.OperandType, il, cursor);
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
public static int IndexOf(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
Type declaringType,
|
||||
string methodName,
|
||||
int startIndex = 0) =>
|
||||
Enumerable.Range(startIndex, calls.Count - startIndex)
|
||||
.FirstOrDefault(
|
||||
index => calls[index].Target.DeclaringType == declaringType
|
||||
&& calls[index].Target.Name == methodName,
|
||||
-1);
|
||||
|
||||
private static OpCode ReadOpCode(byte[] il, ref int cursor)
|
||||
{
|
||||
byte first = il[cursor++];
|
||||
short value = first == 0xFE
|
||||
? unchecked((short)(0xFE00 | il[cursor++]))
|
||||
: first;
|
||||
return OpCodesByValue.TryGetValue(value, out OpCode opCode)
|
||||
? opCode
|
||||
: throw new InvalidOperationException(
|
||||
$"Unknown IL opcode 0x{unchecked((ushort)value):X4}.");
|
||||
}
|
||||
|
||||
private static int OperandSize(OperandType operandType, byte[] il, int cursor) =>
|
||||
operandType switch
|
||||
{
|
||||
OperandType.InlineNone => 0,
|
||||
OperandType.ShortInlineBrTarget or
|
||||
OperandType.ShortInlineI or
|
||||
OperandType.ShortInlineVar => 1,
|
||||
OperandType.InlineVar => 2,
|
||||
OperandType.InlineBrTarget or
|
||||
OperandType.InlineField or
|
||||
OperandType.InlineI or
|
||||
OperandType.InlineMethod or
|
||||
OperandType.InlineSig or
|
||||
OperandType.InlineString or
|
||||
OperandType.InlineTok or
|
||||
OperandType.InlineType or
|
||||
OperandType.ShortInlineR => 4,
|
||||
OperandType.InlineI8 or OperandType.InlineR => 8,
|
||||
OperandType.InlineSwitch =>
|
||||
sizeof(int) + (BitConverter.ToInt32(il, cursor) * sizeof(int)),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(operandType),
|
||||
operandType,
|
||||
"Unsupported IL operand type."),
|
||||
};
|
||||
}
|
||||
|
|
@ -84,6 +84,9 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
|
||||
private readonly List<GpuRecordedCall> _calls = [];
|
||||
private readonly List<Action> _queuedActions = [];
|
||||
private readonly List<RecordingGpuBuffer> _createdBuffers = [];
|
||||
private readonly List<RecordingGpuPipeline> _createdPipelines = [];
|
||||
private readonly List<RecordingGpuSampler> _createdSamplers = [];
|
||||
private readonly Dictionary<GpuSamplerDescription, RecordingGpuSampler> _samplers = [];
|
||||
private readonly byte[] _ring;
|
||||
private readonly Stack<uint> _freeTextureSlots = new();
|
||||
|
|
@ -151,8 +154,18 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
|
||||
public void Clear() => _calls.Clear();
|
||||
|
||||
public IGpuBuffer CreateBuffer(in GpuBufferDescription description) =>
|
||||
new RecordingGpuBuffer(description);
|
||||
public IReadOnlyList<RecordingGpuBuffer> CreatedBuffers => _createdBuffers;
|
||||
|
||||
public IReadOnlyList<RecordingGpuPipeline> CreatedPipelines => _createdPipelines;
|
||||
|
||||
public IReadOnlyList<RecordingGpuSampler> CreatedSamplers => _createdSamplers;
|
||||
|
||||
public IGpuBuffer CreateBuffer(in GpuBufferDescription description)
|
||||
{
|
||||
var buffer = new RecordingGpuBuffer(description);
|
||||
_createdBuffers.Add(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: every image this device made, in creation order.
|
||||
|
|
@ -185,13 +198,16 @@ internal sealed class RecordingGpuDevice : IGpuDevice
|
|||
|
||||
RecordingGpuSampler created = new(description);
|
||||
_samplers.Add(description, created);
|
||||
_createdSamplers.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(description);
|
||||
return new RecordingGpuPipeline(description);
|
||||
var pipeline = new RecordingGpuPipeline(description);
|
||||
_createdPipelines.Add(pipeline);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
|
|
@ -88,48 +90,34 @@ public sealed class ResourceCleanupGroupTests
|
|||
/// re-growing a second resource without re-growing the rollback fails here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TextRendererConstructorOwnsExactlyOneDeviceResource()
|
||||
public void TextRendererConstructionCreatesAndDisposesOnlyOnePipeline()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TextRenderer.cs"));
|
||||
using var device = new RecordingGpuDevice();
|
||||
int buffersBefore = device.CreatedBuffers.Count;
|
||||
int pipelinesBefore = device.CreatedPipelines.Count;
|
||||
int samplersBefore = device.CreatedSamplers.Count;
|
||||
int texturesBefore = device.CreatedTextures.Count;
|
||||
int slotsBefore = device.LiveTextureSlotCount;
|
||||
|
||||
Assert.Equal(1, CountOccurrences(source, "device.CreatePipeline("));
|
||||
Assert.Equal(0, CountOccurrences(source, "device.CreateTexture("));
|
||||
Assert.Equal(0, CountOccurrences(source, "device.CreateBuffer("));
|
||||
Assert.Equal(0, CountOccurrences(source, "device.CreateSampler("));
|
||||
Assert.Equal(0, CountOccurrences(source, "device.RegisterTexture("));
|
||||
var renderer = new TextRenderer(
|
||||
device,
|
||||
new NullGpuFrameSource(),
|
||||
shaderDir: "unused");
|
||||
|
||||
// And the one resource is released.
|
||||
Assert.Contains("public void Dispose() => _pipeline.Dispose();", source, StringComparison.Ordinal);
|
||||
RecordingGpuPipeline pipeline = Assert.Single(
|
||||
device.CreatedPipelines.Skip(pipelinesBefore));
|
||||
Assert.Equal(buffersBefore, device.CreatedBuffers.Count);
|
||||
Assert.Equal(samplersBefore, device.CreatedSamplers.Count);
|
||||
Assert.Equal(texturesBefore, device.CreatedTextures.Count);
|
||||
Assert.Equal(slotsBefore, device.LiveTextureSlotCount);
|
||||
|
||||
renderer.Dispose();
|
||||
|
||||
Assert.True(pipeline.IsDisposed);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string needle)
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
int count = 0;
|
||||
for (int i = source.IndexOf(needle, StringComparison.Ordinal);
|
||||
i >= 0;
|
||||
i = source.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
public IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
|
@ -219,7 +221,7 @@ public sealed class RetailPViewPassExecutorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_production_product_does_not_require_compare_only_referee()
|
||||
public void DrawInside_production_product_uses_retained_routes_without_legacy_partition_when_diagnostics_are_disabled()
|
||||
{
|
||||
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
||||
WorldEntity dynamic = Entity(
|
||||
|
|
@ -274,70 +276,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
Assert.Contains("look-in-punch", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_source_preserves_retail_stage_order_through_typed_operations()
|
||||
{
|
||||
string source = RendererSource();
|
||||
string drawInside = MethodBody(source, "public RetailPViewFrameResult DrawInside(");
|
||||
AssertAppearsInOrder(
|
||||
drawInside,
|
||||
"passes.BeginFrame();",
|
||||
"passes.EmitDiagnostics(ctx, result);",
|
||||
"DrawLandscapeThroughOutsideView(",
|
||||
"DrawExitPortalMasks(ctx, passes",
|
||||
"DrawEnvCellShells(passes, pvFrame);",
|
||||
"DrawCellObjectLists(",
|
||||
"DrawDynamicsLast(");
|
||||
|
||||
string landscape = MethodBody(
|
||||
source,
|
||||
"private void DrawLandscapeThroughOutsideView(");
|
||||
AssertAppearsInOrder(
|
||||
landscape,
|
||||
"passes.SetTerrainClip(slice.Planes);",
|
||||
"passes.DrawLandscapeSlice(",
|
||||
"DrawBuildingLookIns(",
|
||||
"passes.DrawLandscapeSliceLate(",
|
||||
"passes.DrawUnattachedSceneParticles(ctx);",
|
||||
"passes.FlushLandscapeAlpha();",
|
||||
"passes.ClearInteriorDepth();");
|
||||
|
||||
string lookIns = MethodBody(source, "private void DrawBuildingLookIns(");
|
||||
AssertAppearsInOrder(
|
||||
lookIns,
|
||||
"passes.DrawLookInPortalPunch(ctx",
|
||||
"passes.DrawOpaqueCellShells(_shellBatch);",
|
||||
"passes.DrawCellParticles(ctx");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_uses_retained_routes_without_rebuilding_legacy_partition()
|
||||
{
|
||||
string source = RendererSource();
|
||||
string drawInside = MethodBody(
|
||||
source,
|
||||
"public RetailPViewFrameResult DrawInside(");
|
||||
|
||||
Assert.Contains(
|
||||
"if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled)",
|
||||
drawInside,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
drawInside,
|
||||
"_sceneFrameProduct.BuildAndBorrow(",
|
||||
"if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled)",
|
||||
"InteriorEntityPartition.Partition(");
|
||||
|
||||
Assert.Contains(
|
||||
"RenderFrameRouteOwnerSelector.Replace(",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"RenderFrameRouteOwnerSelector.ExceptRoute(",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
|
||||
{
|
||||
|
|
@ -360,61 +298,61 @@ public sealed class RetailPViewPassExecutorTests
|
|||
[Fact]
|
||||
public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics()
|
||||
{
|
||||
string source = ExecutorClassSource();
|
||||
string begin = MethodBody(source, "public void BeginFrame()");
|
||||
Assert.Contains("_particleClassifications.BeginFrame();", begin);
|
||||
MethodInfo begin = typeof(RetailPViewPassExecutor).GetMethod(
|
||||
nameof(RetailPViewPassExecutor.BeginFrame))!;
|
||||
IReadOnlyList<CompiledCall> beginCalls = CompiledCallGraph.Read(begin);
|
||||
Assert.True(
|
||||
CompiledCallGraph.IndexOf(
|
||||
beginCalls,
|
||||
typeof(RetailPViewParticleClassifications),
|
||||
nameof(RetailPViewParticleClassifications.BeginFrame)) >= 0);
|
||||
|
||||
string landscape = MethodBody(source, "public void DrawLandscapeSlice(");
|
||||
AssertAppearsInOrder(
|
||||
landscape,
|
||||
"_terrainDiagnostics.Begin();",
|
||||
"_terrain?.Draw(",
|
||||
"_terrainDiagnostics.Complete();");
|
||||
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
|
||||
nameof(RetailPViewPassExecutor.DrawLandscapeSlice))!;
|
||||
IReadOnlyList<CompiledCall> landscapeCalls = CompiledCallGraph.Read(landscape);
|
||||
int diagnosticsBegin = RequiredCallIndex(
|
||||
landscapeCalls,
|
||||
typeof(TerrainDrawDiagnosticsController),
|
||||
nameof(TerrainDrawDiagnosticsController.Begin));
|
||||
int terrainDraw = RequiredCallIndex(
|
||||
landscapeCalls,
|
||||
typeof(TerrainModernRenderer),
|
||||
nameof(TerrainModernRenderer.Draw));
|
||||
int diagnosticsComplete = RequiredCallIndex(
|
||||
landscapeCalls,
|
||||
typeof(TerrainDrawDiagnosticsController),
|
||||
nameof(TerrainDrawDiagnosticsController.Complete));
|
||||
|
||||
Assert.DoesNotContain("Console.WriteLine", source);
|
||||
Assert.DoesNotContain("Console.WriteLine", RendererSource());
|
||||
Assert.True(diagnosticsBegin < terrainDraw);
|
||||
Assert.True(terrainDraw < diagnosticsComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindow_composes_one_executor_instead_of_a_draw_callback_bag()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"FrameRootComposition.cs"));
|
||||
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
|
||||
"ComposeCore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(compose);
|
||||
|
||||
Assert.Contains("new RetailPViewPassExecutor(", source);
|
||||
Assert.Contains("new WorldScenePViewRenderer(", source);
|
||||
Assert.DoesNotContain("new AcDream.App.Rendering.RetailPViewFrameInput", source);
|
||||
Assert.DoesNotContain("RetailPViewDrawContext", source);
|
||||
Assert.DoesNotContain("DrawLandscapeSlice =", source);
|
||||
Assert.DoesNotContain("DrawExitPortalMasks =", source);
|
||||
}
|
||||
int executor = RequiredCallIndex(
|
||||
calls,
|
||||
typeof(RetailPViewPassExecutor),
|
||||
".ctor");
|
||||
int renderer = RequiredCallIndex(
|
||||
calls,
|
||||
typeof(WorldScenePViewRenderer),
|
||||
".ctor");
|
||||
|
||||
private static string RendererSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"RetailPViewRenderer.cs"));
|
||||
|
||||
private static string ExecutorSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"RetailPViewPassExecutor.cs"));
|
||||
|
||||
private static string ExecutorClassSource()
|
||||
{
|
||||
string source = ExecutorSource();
|
||||
int start = source.IndexOf(
|
||||
"internal sealed class RetailPViewPassExecutor",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, "Missing concrete RetailPViewPassExecutor.");
|
||||
return source[start..];
|
||||
Assert.True(executor < renderer);
|
||||
Assert.Single(
|
||||
calls,
|
||||
call => call.Target.DeclaringType == typeof(RetailPViewPassExecutor)
|
||||
&& call.Target.Name == ".ctor");
|
||||
Assert.Single(
|
||||
calls,
|
||||
call => call.Target.DeclaringType == typeof(WorldScenePViewRenderer)
|
||||
&& call.Target.Name == ".ctor");
|
||||
}
|
||||
|
||||
private static RetailPViewFrameInput Frame(
|
||||
|
|
@ -565,23 +503,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
ParentCellId = parentCellId,
|
||||
};
|
||||
|
||||
private static string MethodBody(string source, string signature)
|
||||
{
|
||||
int start = source.IndexOf(signature, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing method: {signature}");
|
||||
int open = source.IndexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.Length; index++)
|
||||
{
|
||||
if (source[index] == '{')
|
||||
depth++;
|
||||
else if (source[index] == '}' && --depth == 0)
|
||||
return source[start..(index + 1)];
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unterminated method: {signature}");
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
|
|
@ -593,17 +514,14 @@ public sealed class RetailPViewPassExecutorTests
|
|||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
private static int RequiredCallIndex(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
Type declaringType,
|
||||
string methodName)
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
int index = CompiledCallGraph.IndexOf(calls, declaringType, methodName);
|
||||
Assert.True(index >= 0, $"Missing compiled call: {declaringType.Name}.{methodName}");
|
||||
return index;
|
||||
}
|
||||
|
||||
private sealed class DictionaryCellSource(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -290,113 +294,111 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
Assert.Contains(hiddenLight, lighting.PointSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_builder_preserves_the_frame_preparation_order()
|
||||
{
|
||||
string source = BuilderSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"WorldCameraFrame camera = _camera.Resolve();",
|
||||
"_visibility.Begin(in camera, waitingForLogin);",
|
||||
"_settings.Apply(in camera);",
|
||||
"WorldRootFrame roots = _roots.Resolve(in camera);",
|
||||
"_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);",
|
||||
"_visibility.PublishViewProjection(in camera);",
|
||||
"HashSet<uint> animated = _animated.Capture();",
|
||||
"WorldBuildingFrame buildings = _buildings.Gather(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
|
||||
{
|
||||
string source = BuilderSource();
|
||||
MethodInfo prepare = typeof(RuntimeWorldFrameEnvironmentPreparation).GetMethod(
|
||||
nameof(RuntimeWorldFrameEnvironmentPreparation.Prepare))!;
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(prepare);
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);",
|
||||
"_lighting.UpdateViewerLight(roots.PlayerViewPosition);",
|
||||
"_lighting.Tick(camera.Position);",
|
||||
"_lighting.BuildPointLightSnapshot(",
|
||||
"_dispatcher?.SetSceneLights(_lighting.PointSnapshot);",
|
||||
"_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);",
|
||||
"SceneLightingUbo ubo = SceneLightingUbo.Build(",
|
||||
"_lightingUbo?.Upload(ubo);",
|
||||
"RenderingDiagnostics.EmitLight(");
|
||||
Type[] ownerOrder =
|
||||
[
|
||||
typeof(RuntimeWorldFrameEnvironmentPreparation),
|
||||
typeof(LightManager),
|
||||
typeof(LightManager),
|
||||
typeof(LightManager),
|
||||
typeof(WbDrawDispatcher),
|
||||
typeof(EnvCellRenderer),
|
||||
typeof(SceneLightingUbo),
|
||||
typeof(SceneLightingUboBinding),
|
||||
typeof(RenderingDiagnostics),
|
||||
];
|
||||
string[] methodOrder =
|
||||
[
|
||||
"UpdateSunFromSky",
|
||||
nameof(LightManager.UpdateViewerLight),
|
||||
nameof(LightManager.Tick),
|
||||
nameof(LightManager.BuildPointLightSnapshot),
|
||||
nameof(WbDrawDispatcher.SetSceneLights),
|
||||
nameof(EnvCellRenderer.SetPointSnapshot),
|
||||
nameof(SceneLightingUbo.Build),
|
||||
nameof(SceneLightingUboBinding.Upload),
|
||||
nameof(RenderingDiagnostics.EmitLight),
|
||||
];
|
||||
|
||||
AssertCompiledCallOrder(calls, ownerOrder, methodOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
|
||||
{
|
||||
string gameWindow = GameWindowSource();
|
||||
string lifetime = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindowLifetime.cs"));
|
||||
string frameRoot = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"FrameRootComposition.cs"));
|
||||
string worldScene = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"WorldSceneRenderer.cs"));
|
||||
MethodInfo[] windowMethods = typeof(GameWindow).GetMethods(
|
||||
BindingFlags.Instance
|
||||
| BindingFlags.Static
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.NonPublic
|
||||
| BindingFlags.DeclaredOnly);
|
||||
Assert.DoesNotContain(
|
||||
windowMethods,
|
||||
method => method.Name is "UpdateSunFromSky" or "UpdateSkyPes" or "ParseEnvFloat");
|
||||
|
||||
Assert.Equal(
|
||||
1,
|
||||
CountOccurrences(worldScene, "_frames.Build("));
|
||||
Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
|
||||
FieldInfo[] windowFields = typeof(GameWindow).GetFields(
|
||||
BindingFlags.Instance
|
||||
| BindingFlags.Static
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.NonPublic
|
||||
| BindingFlags.DeclaredOnly);
|
||||
Assert.DoesNotContain(
|
||||
"private AcDream.App.Rendering.WorldRenderFrameBuilder?",
|
||||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"private AcDream.App.Rendering.SkyPesFrameController?",
|
||||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
windowFields,
|
||||
field => field.FieldType == typeof(WorldRenderFrameBuilder)
|
||||
|| field.FieldType == typeof(SkyPesFrameController));
|
||||
|
||||
// Gate-fix round (2026-08-17): the renderer's own IsWaitingForLogin
|
||||
// short-circuit is deleted — the frame gate
|
||||
// (LocalPlayerTeleportRenderStateSource) folds the waiting state
|
||||
// into PortalViewportVisible, so the portal-visible return above
|
||||
// covers every waiting frame (one gate computes, this phase
|
||||
// enforces).
|
||||
AssertAppearsInOrder(
|
||||
worldScene,
|
||||
"_alpha.BeginFrame();",
|
||||
"_frames.Build(",
|
||||
"_passes.DrawFlatTerrain(",
|
||||
"NormalWorldDrawn: true");
|
||||
AssertAppearsInOrder(
|
||||
frameRoot,
|
||||
"var skyPesFrame = new SkyPesFrameController(",
|
||||
"var worldRenderFrameBuilder =",
|
||||
"new RenderFrameOrchestrator(",
|
||||
"d.FrameGraphs.PublishOwned(");
|
||||
AssertAppearsInOrder(
|
||||
lifetime,
|
||||
"frame.FrameGraphPublication?.Dispose()",
|
||||
"Hard(\"equipped children\"",
|
||||
"Hard(\"effect network state\"",
|
||||
"Hard(\"audio\"",
|
||||
"Hard(\"mesh draw dispatcher\"",
|
||||
"Hard(\"environment cells\"",
|
||||
"Hard(\"scene lighting\"");
|
||||
}
|
||||
MethodInfo render = typeof(WorldSceneRenderer).GetMethod(
|
||||
nameof(WorldSceneRenderer.Render))!;
|
||||
IReadOnlyList<CompiledCall> renderCalls = CompiledCallGraph.Read(render);
|
||||
AssertCompiledCallOrder(
|
||||
renderCalls,
|
||||
[
|
||||
typeof(IWorldSceneAlphaFrame),
|
||||
typeof(IWorldRenderFrameBuilder),
|
||||
typeof(IWorldScenePassExecutor),
|
||||
typeof(WorldRenderFrameOutcome),
|
||||
],
|
||||
[
|
||||
nameof(IWorldSceneAlphaFrame.BeginFrame),
|
||||
nameof(IWorldRenderFrameBuilder.Build),
|
||||
nameof(IWorldScenePassExecutor.DrawFlatTerrain),
|
||||
".ctor",
|
||||
]);
|
||||
Assert.Single(
|
||||
renderCalls,
|
||||
call => call.Target.DeclaringType == typeof(IWorldRenderFrameBuilder)
|
||||
&& call.Target.Name == nameof(IWorldRenderFrameBuilder.Build));
|
||||
|
||||
private static string BuilderSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"WorldRenderFrameBuilder.cs"));
|
||||
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
|
||||
"ComposeCore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
IReadOnlyList<CompiledCall> composeCalls = CompiledCallGraph.Read(compose);
|
||||
AssertCompiledCallOrder(
|
||||
composeCalls,
|
||||
[
|
||||
typeof(SkyPesFrameController),
|
||||
typeof(WorldRenderFrameBuilder),
|
||||
typeof(RenderFrameOrchestrator),
|
||||
typeof(GameFrameGraphSlot),
|
||||
],
|
||||
[".ctor", ".ctor", ".ctor", nameof(GameFrameGraphSlot.PublishOwned)]);
|
||||
|
||||
// The former source assertion also mixed in teardown ordering. That
|
||||
// distinct lifetime contract remains covered by the dedicated lifetime
|
||||
// suite and is reconciled with the later host/lifetime R3 batch.
|
||||
}
|
||||
|
||||
private static WorldCameraFrame CameraFrame(FlyCamera camera) => new(
|
||||
camera,
|
||||
|
|
@ -419,51 +421,27 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
seenOutside,
|
||||
containmentBsp: null);
|
||||
|
||||
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
private static int CountOccurrences(string source, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
private static void AssertCompiledCallOrder(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
IReadOnlyList<Type> declaringTypes,
|
||||
IReadOnlyList<string> methodNames)
|
||||
{
|
||||
Assert.Equal(declaringTypes.Count, methodNames.Count);
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
for (int index = 0; index < declaringTypes.Count; index++)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
int next = CompiledCallGraph.IndexOf(
|
||||
calls,
|
||||
declaringTypes[index],
|
||||
methodNames[index],
|
||||
cursor + 1);
|
||||
Assert.True(
|
||||
next > cursor,
|
||||
$"Missing or out-of-order compiled call: {declaringTypes[index].Name}.{methodNames[index]}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
private sealed class RecordingCamera(
|
||||
List<string> calls,
|
||||
WorldCameraFrame result) : IWorldFrameCameraSource
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
|
|
@ -326,20 +328,46 @@ public sealed class WorldSceneRendererTests
|
|||
[Fact]
|
||||
public void GameWindow_DelegatesWorldRenderingWithoutOwningDrawBranches()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"FrameRootComposition.cs"));
|
||||
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
|
||||
"ComposeCore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(compose);
|
||||
|
||||
Assert.Contains("new WorldSceneRenderer(", source);
|
||||
Assert.Contains("new RenderFrameOrchestrator(", source);
|
||||
Assert.DoesNotContain("_worldSceneRenderer", source);
|
||||
Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source);
|
||||
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
|
||||
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
|
||||
Assert.DoesNotContain("SkipWorldGeometry:", source);
|
||||
int worldRenderer = RequiredCallIndex(
|
||||
calls,
|
||||
typeof(WorldSceneRenderer),
|
||||
".ctor");
|
||||
int frameOrchestrator = RequiredCallIndex(
|
||||
calls,
|
||||
typeof(RenderFrameOrchestrator),
|
||||
".ctor");
|
||||
Assert.True(worldRenderer < frameOrchestrator);
|
||||
Assert.Single(
|
||||
calls,
|
||||
call => call.Target.DeclaringType == typeof(WorldSceneRenderer)
|
||||
&& call.Target.Name == ".ctor");
|
||||
Assert.Single(
|
||||
calls,
|
||||
call => call.Target.DeclaringType == typeof(RenderFrameOrchestrator)
|
||||
&& call.Target.Name == ".ctor");
|
||||
|
||||
FieldInfo[] windowFields = typeof(GameWindow).GetFields(
|
||||
BindingFlags.Instance
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.NonPublic
|
||||
| BindingFlags.DeclaredOnly);
|
||||
Assert.DoesNotContain(
|
||||
windowFields,
|
||||
field => field.FieldType == typeof(WorldSceneRenderer)
|
||||
|| field.FieldType == typeof(RetailPViewRenderer)
|
||||
|| field.FieldType == typeof(TerrainDrawDiagnosticsController)
|
||||
|| field.FieldType == typeof(RenderFrameOrchestrator));
|
||||
|
||||
FieldInfo worldPhase = Assert.Single(
|
||||
typeof(RenderFrameOrchestrator).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => field.FieldType == typeof(IWorldSceneFramePhase));
|
||||
Assert.Equal("_world", worldPhase.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -771,28 +799,13 @@ public sealed class WorldSceneRendererTests
|
|||
[]);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
private static int RequiredCallIndex(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
Type declaringType,
|
||||
string methodName)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += value.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
int index = CompiledCallGraph.IndexOf(calls, declaringType, methodName);
|
||||
Assert.True(index >= 0, $"Missing compiled call: {declaringType.Name}.{methodName}");
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue