From 5a333690740a4e84d55bfca1b5befe971b73e3aa Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 14:58:25 +0200 Subject: [PATCH] test: replace render source freezes --- .../2026-08-18-r3-test-truth-ledger.md | 68 ++++- .../Architecture/CompiledCallGraph.cs | 109 ++++++++ .../Rendering/Gpu/RecordingGpuDevice.cs | 22 +- .../Rendering/ResourceCleanupGroupTests.cs | 62 ++--- .../Rendering/RetailPViewPassExecutorTests.cs | 194 +++++---------- .../Rendering/WorldRenderFrameBuilderTests.cs | 232 ++++++++---------- .../Rendering/WorldSceneRendererTests.cs | 83 ++++--- 7 files changed, 425 insertions(+), 345 deletions(-) create mode 100644 tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs diff --git a/docs/reviews/2026-08-18-r3-test-truth-ledger.md b/docs/reviews/2026-08-18-r3-test-truth-ledger.md index 240a090c..d3b65536 100644 --- a/docs/reviews/2026-08-18-r3-test-truth-ledger.md +++ b/docs/reviews/2026-08-18-r3-test-truth-ledger.md @@ -38,12 +38,12 @@ pwsh ./tools/audit-test-inventory.ps1 The generated JSON lives under ignored `artifacts/test-audit/`; it is not a second 6.8 MB checked-in source of truth. The script and this reviewed ledger are durable, while paths and line numbers regenerate after every batch. The -inventory refreshed through Batch U reports: +inventory refreshed through Batch W reports: | Syntax signal | Current count | |---|---:| -| Tracked/parsed C# test files | 1,256 / 1,256 | -| Attributed test methods (not expanded theory cases) | 11,447 | +| Tracked/parsed C# test files | 1,254 / 1,254 | +| Attributed test methods (not expanded theory cases) | 11,416 | | Exact duplicate-data rows | 0 | | Reviewed body-equivalent groups / methods | 11 / 27 | | Methods containing at least one empty `return;` | 138 | @@ -56,7 +56,7 @@ inventory refreshed through Batch U reports: | Methods directly using `Thread.Sleep` / `Task.Delay` | 15 / 14 | | Cancellable-infinite-only / elapsed-time methods | 5 / 24 | | Methods directly reading environment variables | 47 | -| Methods directly / through same-file helpers reading `.cs` source text | 67 / 107 | +| Methods directly / through same-file helpers reading `.cs` source text | 63 / 98 | The three remaining prerequisite-return candidates are all reviewed branch false positives: the two Windows/Linux factory assertions and the launcher's @@ -140,7 +140,7 @@ identifiable in the lane report. | T-009 wall-clock double-click tests | resolved in batch E | Four sleeps were replaced by a deterministic test clock behind an internal factory overload. The production factory still reads `Environment.TickCount64` exactly as before. | | T-010 two useless cases | high-confidence cleanup batch A | Delete `SmokeTest.TestProject_IsWired` and `ChaseCameraTests.ImplementsICamera`; compilation already proves both claims. | | T-011 diagnostic-only methods | resolved in batches C, L, and R | The reviewed current set is 82 methods / 103 cases. All carry `Purpose=Diagnostic`, preserving the apparatus while removing it from release pass totals. Batch R catches investigations whose only assertion validates fixture/DAT availability, which the original mechanical output-only scan could not distinguish from an oracle. | -| T-012 source-text freezes | direct-read map completed in batch I, helper-mediated gap corrected in batch U, and staged replacement approved | Seventeen whole-tree architecture rules and five cross-artifact contracts stay. The other 85 are literal implementation or test-model freezes; approval authorizes retiring each only beside its semantic/behavioral replacement. | +| T-012 source-text freezes | direct-read map completed in batch I, helper-mediated gap corrected in batch U, and staged replacement active | Seventeen whole-tree architecture rules and five cross-artifact contracts stay. Batch W retires the first nine literal implementation freezes beside stronger behavior or compiled-metadata evidence; 76 staged replacements remain. | | T-013 controller self-comparison | high-confidence cleanup batch A | Capture the first controller next to the first body and compare every retry with that reference. | | T-014 seven load-sensitive tests | six mechanisms repaired in batch F; one product defect classified | Virtual/wall-clock mixing, tiered-JIT allocation noise, a live controller clock, and a ThreadPool-start timing oracle were removed without weakening behavioral contracts. `DatSoundCache` #321 is a real in-flight-entry race and now runs as `Status=KnownFailure` pending a product fix. | | T-015 four non-prerequisite skips | resolved in batches A/B | PVS scaffold deleted with rationale preserved; redundant chat/radar generators deleted in favor of the comprehensive Manual lane; tower oracle is `Status=KnownFailure`. | @@ -1027,3 +1027,61 @@ Verification: and - the no-retry complete hermetic Release gate passes 14,351/14,351 with zero skips or failures across all 12 test assemblies, exactly 28 below Batch U. + +## Batch W render delegation and cleanup source-freeze replacement + +Batch W retires the first nine of the 85 approved implementation-text freezes +without changing product code. Six test methods now inspect runtime behavior, +reflection metadata, or compiled call/new-object edges; three redundant source +tests are removed because an existing behavioral trace already asserts the +same contract more strongly. + +The exact decisions are: + +- `TextRendererConstructorOwnsExactlyOneDeviceResource` no longer counts + constructor strings. `TextRendererConstructionCreatesAndDisposesOnlyOnePipeline` + constructs the real renderer through `RecordingGpuDevice`, proves that only + one pipeline is added (no buffer, texture, sampler, or texture slot), and + proves that exact pipeline is disposed. The V4a/V6d rollback rationale stays + beside the test. The recording device gained read-only created-resource lists + solely so tests can observe those device calls. +- `Renderer_source_preserves_retail_stage_order_through_typed_operations` is + removed. The existing outdoor and interior `DrawInside` tests already drive + the real renderer and assert the typed operation sequence, including early + and late landscape, particle flush, interior clear, masks, shells, and entity + routes. +- `Production_uses_retained_routes_without_rebuilding_legacy_partition` is + removed. The existing production-product test is renamed to state this + contract and continues to prove one actual retained candidate with zero + compare-only referee work. Exact private selector spellings are not a product + oracle. +- `Production_builder_preserves_the_frame_preparation_order` is removed. The + existing builder test already supplies recording implementations of every + typed source and asserts the complete call order plus the borrowed result. +- executor reset/diagnostic bracketing, the one-executor/one-PView composition, + lighting snapshot-before-UBO order, typed world-frame build, local frame-root + composition, and GameWindow delegation now inspect compiled call/new-object + edges and reflected owner fields. `CompiledCallGraph` is shared test-only + infrastructure that parses the built method body; formatting, comments, local + variable names, and source paths cannot satisfy or break these contracts. + +Two mixed source assertions were deliberately narrowed rather than silently +carried forward. Absence of `Console.WriteLine` is an implementation-style +check with no output oracle and is not treated as render behavior. The old +world-scene source test also embedded a separate teardown-order claim; that +claim remains with the dedicated lifetime suite and is reconciled in the +approved host/lifetime replacement batch instead of coupling teardown to frame +construction. + +Verification: + +- the four directly affected App suites pass 39/39; +- the complete locked Release build covers all 44 projects with zero warnings + and zero errors; +- the no-retry complete hermetic Release gate passes 14,348/14,348 with zero + skips or failures across all 12 test assemblies; the exact three-case + reduction is the three redundant source tests above; and +- the regenerated 1,254-file inventory parses every file and reduces direct + source readers from 67 to 63 and total direct/helper readers from 107 to 98. + The remaining 98 reconcile to the 22 approved retained policies/contracts + and 76 staged replacements. diff --git a/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs b/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs new file mode 100644 index 00000000..29e10362 --- /dev/null +++ b/tests/AcDream.App.Tests/Architecture/CompiledCallGraph.cs @@ -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); + +/// +/// 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. +/// +internal static class CompiledCallGraph +{ + private static readonly IReadOnlyDictionary 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 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(); + + 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 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."), + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs index ff582e45..3dafc000 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs @@ -84,6 +84,9 @@ internal sealed class RecordingGpuDevice : IGpuDevice private readonly List _calls = []; private readonly List _queuedActions = []; + private readonly List _createdBuffers = []; + private readonly List _createdPipelines = []; + private readonly List _createdSamplers = []; private readonly Dictionary _samplers = []; private readonly byte[] _ring; private readonly Stack _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 CreatedBuffers => _createdBuffers; + + public IReadOnlyList CreatedPipelines => _createdPipelines; + + public IReadOnlyList CreatedSamplers => _createdSamplers; + + public IGpuBuffer CreateBuffer(in GpuBufferDescription description) + { + var buffer = new RecordingGpuBuffer(description); + _createdBuffers.Add(buffer); + return buffer; + } /// /// 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) => diff --git a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs index 736ac2b0..885d749c 100644 --- a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs @@ -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. /// [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; } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index ac1047f6..5cb62a18 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -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 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 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 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 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( diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index ca519a80..e00587f2 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -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 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 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 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 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 calls, + IReadOnlyList declaringTypes, + IReadOnlyList 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 calls, WorldCameraFrame result) : IWorldFrameCameraSource diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index 1be9e085..dd620f14 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -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 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 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; } }