refactor(render): extract frame presentation diagnostics

This commit is contained in:
Erik 2026-07-22 05:02:31 +02:00
parent 7e4cfb37c3
commit 733126a272
20 changed files with 3767 additions and 1021 deletions

View file

@ -29,7 +29,7 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(controller.TryRequest("login_stable", out string error), error);
Assert.False(controller.IsComplete("login_stable"));
controller.CapturePending();
Assert.True(controller.CapturePending());
Assert.True(controller.IsComplete("login_stable"));
string path = Path.Combine(directory, "login_stable.png");
@ -48,6 +48,29 @@ public sealed class WorldLifecycleAutomationControllerTests
}
}
[Fact]
public void ScreenshotCapture_ReportsNoWorkAndFailedCapture()
{
string directory = NewDirectory();
var controller = new FrameScreenshotController(
() => (0, 0),
(_, _) => [],
directory);
try
{
Assert.False(controller.CapturePending());
Assert.True(controller.TryRequest("bad_frame", out string error), error);
Assert.False(controller.CapturePending());
Assert.False(controller.IsComplete("bad_frame"));
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Theory]
[InlineData("")]
[InlineData("../escape")]

View file

@ -0,0 +1,167 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Rendering;
public sealed class DebugVmRenderFactsPublisherTests
{
[Fact]
public void Defaults_MatchThePriorGameWindowCache()
{
var publisher = new DebugVmRenderFactsPublisher();
Assert.Equal(DebugVmRenderFacts.Initial, publisher.DebugVmFacts);
Assert.Equal(float.PositiveInfinity, publisher.DebugVmFacts.NearestObjectDistance);
Assert.Equal("-", publisher.DebugVmFacts.NearestObjectLabel);
Assert.False(publisher.DebugVmFacts.Colliding);
}
[Fact]
public void InactiveConsumer_DoesNotEnumerateOrReplacePriorFacts()
{
var publisher = new DebugVmRenderFactsPublisher();
publisher.PublishDebugVmFacts(
true,
3,
8,
Vector3.Zero,
[Entry(1, new Vector3(5, 0, 100), radius: 1)]);
DebugVmRenderFacts prior = publisher.DebugVmFacts;
var shadows = new CountingEnumerable([Entry(2, Vector3.One, 1)]);
publisher.PublishDebugVmFacts(
false,
99,
100,
new Vector3(90, 90, 90),
shadows);
Assert.Equal(0, shadows.EnumerationCount);
Assert.Equal(prior, publisher.DebugVmFacts);
}
[Fact]
public void Publish_UsesHorizontalDistanceAndSubtractsBothRadii()
{
var publisher = new DebugVmRenderFactsPublisher();
var origin = new Vector3(10, 20, -500);
publisher.PublishDebugVmFacts(
true,
7,
11,
origin,
[
Entry(0xA, new Vector3(16, 28, 500), radius: 1.52f),
Entry(0xB, new Vector3(30, 20, -500), radius: 2f),
]);
DebugVmRenderFacts facts = publisher.DebugVmFacts;
Assert.Equal(7, facts.VisibleLandblocks);
Assert.Equal(11, facts.TotalLandblocks);
Assert.Equal(8.0f, facts.NearestObjectDistance, precision: 5);
Assert.Equal("0x0000000A Cylinder", facts.NearestObjectLabel);
Assert.False(facts.Colliding);
}
[Fact]
public void Publish_ClampsOverlapToZeroAndUsesUnclampedDistanceForCollision()
{
var publisher = new DebugVmRenderFactsPublisher();
publisher.PublishDebugVmFacts(
true,
1,
2,
Vector3.Zero,
[Entry(0x1234, new Vector3(1, 0, 200), radius: 0.6f)]);
Assert.Equal(0f, publisher.DebugVmFacts.NearestObjectDistance);
Assert.True(publisher.DebugVmFacts.Colliding);
Assert.Equal("0x00001234 Cylinder", publisher.DebugVmFacts.NearestObjectLabel);
}
[Fact]
public void Publish_DistanceAboveContactThresholdIsClearAndTieKeepsFirstEntry()
{
var publisher = new DebugVmRenderFactsPublisher();
float centerDistance = 1f + DebugVmRenderFactsPublisher.PlayerCollisionRadius
+ DebugVmRenderFactsPublisher.ContactThreshold + 0.001f;
publisher.PublishDebugVmFacts(
true,
0,
0,
Vector3.Zero,
[
Entry(0x11, new Vector3(centerDistance, 0, 0), 1f),
Entry(0x22, new Vector3(0, centerDistance, 0), 1f),
]);
Assert.True(publisher.DebugVmFacts.NearestObjectDistance
> DebugVmRenderFactsPublisher.ContactThreshold);
Assert.False(publisher.DebugVmFacts.Colliding);
Assert.Equal("0x00000011 Cylinder", publisher.DebugVmFacts.NearestObjectLabel);
}
[Fact]
public void Publish_EmptySequenceRestoresTheEmptyNearestFacts()
{
var publisher = new DebugVmRenderFactsPublisher();
publisher.PublishDebugVmFacts(
true,
1,
1,
Vector3.Zero,
[Entry(1, Vector3.Zero, 1)]);
publisher.PublishDebugVmFacts(true, 4, 9, Vector3.Zero, []);
Assert.Equal(4, publisher.DebugVmFacts.VisibleLandblocks);
Assert.Equal(9, publisher.DebugVmFacts.TotalLandblocks);
Assert.Equal(float.PositiveInfinity, publisher.DebugVmFacts.NearestObjectDistance);
Assert.Equal("-", publisher.DebugVmFacts.NearestObjectLabel);
Assert.False(publisher.DebugVmFacts.Colliding);
}
[Fact]
public void Publisher_RetainsNoRuntimeOwnerDelegateOrBorrowedSequence()
{
FieldInfo[] fields = typeof(DebugVmRenderFactsPublisher).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Single(fields);
Assert.Equal(typeof(DebugVmRenderFacts), fields[0].FieldType);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
Assert.DoesNotContain(fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType));
Assert.DoesNotContain(fields, field =>
typeof(IEnumerable<ShadowEntry>).IsAssignableFrom(field.FieldType));
Assert.True(typeof(IDebugVmRenderFactsSource).IsAssignableFrom(
typeof(DebugVmRenderFactsPublisher)));
}
private static ShadowEntry Entry(uint id, Vector3 position, float radius) => new(
EntityId: id,
GfxObjId: 0,
Position: position,
Rotation: Quaternion.Identity,
Radius: radius,
CollisionType: ShadowCollisionType.Cylinder);
private sealed class CountingEnumerable(IReadOnlyList<ShadowEntry> entries)
: IEnumerable<ShadowEntry>
{
public int EnumerationCount { get; private set; }
public IEnumerator<ShadowEntry> GetEnumerator()
{
EnumerationCount++;
return entries.GetEnumerator();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
GetEnumerator();
}
}

View file

@ -0,0 +1,333 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Rendering;
public sealed class DevToolsFramePresenterTests
{
[Fact]
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls);
var commands = new RecordingCommandSource();
var presenter = Create(backend, commands: commands);
presenter.BeginFrame(0.25f);
presenter.Render(0.5, 1280, 720);
Assert.Equal(
[
"begin:0.25",
"begin-bar",
"begin-menu:View",
"begin-menu:Camera",
"end-bar",
"panels:0.5",
"draw-data",
],
calls);
Assert.Same(commands.Current, backend.Contexts.Single().Commands);
}
[Fact]
public void Render_ResolvesTheCurrentCommandBusEveryFrame()
{
var backend = new RecordingBackend([]);
var first = new RecordingCommandBus();
var second = new RecordingCommandBus();
var commands = new RecordingCommandSource { Current = first };
var presenter = Create(backend, commands: commands);
presenter.Render(0.1, 800, 600);
commands.Current = second;
presenter.Render(0.2, 800, 600);
Assert.Same(first, backend.Contexts[0].Commands);
Assert.Same(second, backend.Contexts[1].Commands);
}
[Fact]
public void ViewMenu_TogglesExactPanelsAndUsesCurrentViewportForReset()
{
var backend = new RecordingBackend([]);
backend.OpenMenus.Add("View");
backend.ClickedItems.UnionWith(
["Settings", "Vitals", "Chat", "Debug", "Reset window layout"]);
var panels = new RecordingPanels();
var presenter = Create(backend, panels: panels);
presenter.Render(0.1, 1920, 1080);
Assert.Equal(
[
DevToolsPanelKind.Settings,
DevToolsPanelKind.Vitals,
DevToolsPanelKind.Chat,
DevToolsPanelKind.Debug,
],
panels.Toggles);
Assert.Equal(
[
new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)),
new LayoutCall("Chat", new Vector2(10f, 760f), new Vector2(450f, 300f)),
new LayoutCall("Debug", new Vector2(1540f, 30f), new Vector2(370f, 520f)),
new LayoutCall("Settings", new Vector2(610f, 290f), new Vector2(700f, 500f)),
],
backend.Layouts.Select(call => call.WithoutCondition()));
Assert.All(
backend.Layouts,
call => Assert.Equal(DevToolsPanelLayoutCondition.Always, call.Condition));
}
[Fact]
public void CameraMenu_UsesFlyLabelAndRoundTripsCollisionState()
{
var backend = new RecordingBackend([]);
backend.OpenMenus.Add("Camera");
backend.ClickedItems.UnionWith(
["Exit Free-Fly Mode", "Collide Camera (spring arm)"]);
var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true };
var presenter = Create(backend, camera: camera);
presenter.Render(0.1, 1280, 720);
Assert.Equal(1, camera.ToggleCount);
Assert.False(camera.CollideCamera);
Assert.Contains(
backend.MenuItems,
item => item.Label == "Collide Camera (spring arm)" && item.Selected);
}
[Fact]
public void MissingSettingsPanel_IsInertAndSkippedFromLayout()
{
var backend = new RecordingBackend([]);
backend.OpenMenus.Add("View");
backend.ClickedItems.Add("Settings");
var panels = new RecordingPanels { HasSettings = false };
var presenter = Create(backend, panels: panels);
presenter.ToggleSettingsPanel();
presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver);
presenter.Render(0.1, 1280, 720);
Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles);
Assert.DoesNotContain(backend.MenuItems, item => item.Label == "Settings");
Assert.DoesNotContain(backend.Layouts, call => call.Title == "Settings");
}
[Theory]
[InlineData(1280, 720, 900, 400, 290, 110)]
[InlineData(1920, 1080, 1540, 760, 610, 290)]
[InlineData(100, 100, 100, 0, -110, -90)]
public void ResetLayout_PreservesExactFormulasAndMinimumViewport(
int width,
int height,
int debugX,
int chatY,
int settingsX,
int settingsY)
{
var backend = new RecordingBackend([]);
var presenter = Create(backend);
presenter.ResetLayout(width, height, DevToolsPanelLayoutCondition.FirstUseEver);
Assert.Collection(
backend.Layouts,
call => Assert.Equal(
new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)),
call.WithoutCondition()),
call => Assert.Equal(
new LayoutCall("Chat", new Vector2(10f, chatY), new Vector2(450f, 300f)),
call.WithoutCondition()),
call => Assert.Equal(
new LayoutCall("Debug", new Vector2(debugX, 30f), new Vector2(370f, 520f)),
call.WithoutCondition()),
call => Assert.Equal(
new LayoutCall(
"Settings",
new Vector2(settingsX, settingsY),
new Vector2(700f, 500f)),
call.WithoutCondition()));
}
[Fact]
public void InputFacingActionsDelegateToTheTypedPanelOwner()
{
var panels = new RecordingPanels();
var presenter = Create(new RecordingBackend([]), panels: panels);
presenter.ToggleDebugPanel();
presenter.FocusChatInput();
presenter.ToggleSettingsPanel();
Assert.Equal(
[DevToolsPanelKind.Debug, DevToolsPanelKind.Settings],
panels.Toggles);
Assert.Equal(1, panels.FocusChatCount);
Assert.Equal(["Debug panel ON"], panels.Toasts);
}
[Fact]
public void PresenterAndAdaptersHaveNoWindowOrDelegateBackReferences()
{
Type[] owners =
[
typeof(DevToolsFramePresenter),
typeof(ImGuiDevToolsFrameBackend),
typeof(DevToolsCameraMenuOperations),
typeof(DevToolsCommandBusSource),
typeof(DevToolsPanelSet),
];
foreach (Type owner in owners)
{
FieldInfo[] fields = owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
Assert.DoesNotContain(
fields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
Assert.DoesNotContain(
fields,
field => field.FieldType == typeof(Silk.NET.Windowing.IWindow));
}
Assert.False(typeof(IDisposable).IsAssignableFrom(
typeof(ImGuiDevToolsFrameBackend)));
}
private static DevToolsFramePresenter Create(
RecordingBackend backend,
RecordingCamera? camera = null,
RecordingCommandSource? commands = null,
RecordingPanels? panels = null) =>
new(
backend,
camera ?? new RecordingCamera(),
commands ?? new RecordingCommandSource(),
new FrameProfiler(),
panels ?? new RecordingPanels());
private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend
{
public HashSet<string> OpenMenus { get; } = [];
public HashSet<string> ClickedItems { get; } = [];
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
public List<PanelContext> Contexts { get; } = [];
public List<LayoutCallWithCondition> Layouts { get; } = [];
public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}");
public bool BeginMainMenuBar()
{
calls.Add("begin-bar");
return true;
}
public void EndMainMenuBar() => calls.Add("end-bar");
public bool BeginMenu(string label)
{
calls.Add($"begin-menu:{label}");
return OpenMenus.Contains(label);
}
public void EndMenu() => calls.Add("end-menu");
public bool MenuItem(string label, string? shortcut = null, bool selected = false)
{
MenuItems.Add((label, shortcut, selected));
return ClickedItems.Contains(label);
}
public void Separator() => calls.Add("separator");
public void RenderPanels(PanelContext context)
{
Contexts.Add(context);
calls.Add($"panels:{context.DeltaSeconds}");
}
public void RenderDrawData() => calls.Add("draw-data");
public void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition) =>
Layouts.Add(new LayoutCallWithCondition(title, position, size, condition));
}
private sealed class RecordingCamera : IDevToolsCameraMenuOperations
{
public bool IsFlyMode { get; init; }
public bool CollideCamera { get; set; }
public int ToggleCount { get; private set; }
public void ToggleFlyOrChase() => ToggleCount++;
}
private sealed class RecordingCommandSource : IDevToolsCommandBusSource
{
public ICommandBus Current { get; set; } = new RecordingCommandBus();
}
private sealed class RecordingCommandBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull
{
}
}
private sealed class RecordingPanels : IDevToolsPanelSet
{
private readonly HashSet<DevToolsPanelKind> _visible = [];
public bool HasSettings { get; init; } = true;
public List<DevToolsPanelKind> Toggles { get; } = [];
public List<string> Toasts { get; } = [];
public int FocusChatCount { get; private set; }
public bool Contains(DevToolsPanelKind kind) =>
kind != DevToolsPanelKind.Settings || HasSettings;
public string? GetTitle(DevToolsPanelKind kind) => Contains(kind)
? kind.ToString()
: null;
public bool IsVisible(DevToolsPanelKind kind) => _visible.Contains(kind);
public void Toggle(DevToolsPanelKind kind)
{
if (!Contains(kind))
return;
Toggles.Add(kind);
if (!_visible.Add(kind))
_visible.Remove(kind);
}
public void FocusChatInput() => FocusChatCount++;
public void AddDebugToast(string message) => Toasts.Add(message);
}
private readonly record struct LayoutCall(
string Title,
Vector2 Position,
Vector2 Size);
private readonly record struct LayoutCallWithCondition(
string Title,
Vector2 Position,
Vector2 Size,
DevToolsPanelLayoutCondition Condition)
{
public LayoutCall WithoutCondition() => new(Title, Position, Size);
}
}

View file

@ -0,0 +1,167 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class GameWindowRenderLeafCompositionTests
{
[Fact]
public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder()
{
string source = GameWindowSource();
AssertAppearsInOrder(
source,
"_localPlayerTeleport?.DrawPortalViewport(",
"_paperdollFramePresenter?.Render();",
"_retailUiRuntime.Tick(deltaSeconds);",
"_devToolsFramePresenter?.Render(",
"_frameScreenshots?.CapturePending() == true;",
"_renderFrameDiagnostics?.Publish(");
}
[Fact]
public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce()
{
string source = GameWindowSource();
AssertAppearsInOrder(
source,
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
"Weather.Tick(nowSeconds: _weatherAccum",
"_retailSelectionScene?.BeginFrame();");
Assert.Equal(1, CountOccurrences(source, "_wbDrawDispatcher?.BeginFrame("));
}
[Fact]
public void ProductionComposition_RemovesLegacyLeafOwnership()
{
string source = GameWindowSource();
string[] removed =
[
"_imguiBootstrap",
"_panelHost",
"_paperdollDollDirty",
"RefreshPaperdollDoll",
"ApplyPaperdollPose",
"ResolvePaperdollPoseDid",
"EnumerateDebugPanel",
"ResetPanelLayout",
"SetPanelLayout",
"_lastRenderSignature",
"private void EmitRenderSignatureIfChanged(",
"private void EmitRetailPViewDiagnostics(",
"EmitGlStateTripwireIfChanged();",
"EmitClipRouteScissorProbe(scissor",
"_lastVisibleLandblocks",
"_perfAccum",
];
foreach (string identifier in removed)
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
Assert.Contains("_inputCapture.WantCaptureMouse", source);
}
[Fact]
public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
{
string source = GameWindowSource();
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"new(\"portal tunnel\"",
"new(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
Assert.DoesNotContain("_devToolsBackend?.Dispose()", source);
}
[Fact]
public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts()
{
string source = GameWindowSource();
Assert.Contains("bool normalWorldDrawn = false;", source);
AssertAppearsInOrder(
source,
"if (IsLiveModeWaitingForLogin)",
"goto SkipWorldGeometry;",
"normalWorldDrawn = true;",
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
"normalWorldDrawn),",
"screenshotCaptured)));");
}
[Fact]
public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface()
{
string source = GameWindowSource();
AssertAppearsInOrder(
source,
"PaperdollViewportWidget is { } paperdollViewport",
"InventoryFrame is { } paperdollInventoryFrame",
"new AcDream.App.Rendering.PaperdollFramePresenter(");
Assert.DoesNotContain("Paperdoll inventory frame is required.", source);
}
[Fact]
public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites()
{
string source = GameWindowSource();
AssertAppearsInOrder(
source,
"_worldRenderDiagnostics?.PublishTerrainDiagnostics(",
"PublishFrameDiagnostics();",
"_frameDiagLastPublicationMilliseconds = now;");
}
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
private static void AssertAppearsInOrder(string source, params string[] needles)
{
int cursor = -1;
foreach (string needle in needles)
{
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}");
cursor = next;
}
}
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 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.");
}
}

View file

@ -0,0 +1,256 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Input;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests
{
[Fact]
public void HiddenView_DoesNotBuildOrRender()
{
var renderer = new RecordingRenderer();
var view = new RecordingView { Visible = false };
var factory = new RecordingFactory();
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(0, factory.BuildCount);
Assert.Equal(0, renderer.RenderCount);
Assert.Empty(view.TextureHandles);
}
[Fact]
public void FirstVisibleFrame_BuildsThenRendersAndPublishesTexture()
{
var doll = CreateDoll();
var renderer = new RecordingRenderer { TextureHandle = 91u };
var view = new RecordingView { Width = 240, Height = 320 };
var factory = new RecordingFactory { Doll = doll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
Assert.Same(doll, renderer.Dolls.Single());
Assert.Equal([(240, 320)], renderer.RenderSizes);
Assert.Equal([91u], view.TextureHandles);
}
[Fact]
public void CleanFrame_ReusesDollUntilMarkedDirty()
{
var renderer = new RecordingRenderer();
var view = new RecordingView();
var factory = new RecordingFactory
{
Doll = CreateDoll(),
};
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
presenter.Render();
presenter.MarkDirty();
presenter.Render();
Assert.Equal(2, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count);
Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
private static WorldEntity CreateDoll() => new()
{
Id = 42u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void MissingPlayer_ClearsOldDollPublishesEmptyTextureAndRetries()
{
var renderer = new RecordingRenderer { TextureHandle = 0u };
var view = new RecordingView();
var factory = new RecordingFactory { CanBuild = false };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(2, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count);
Assert.All(renderer.Dolls, Assert.Null);
Assert.Equal(2, renderer.RenderCount);
Assert.Equal([0u, 0u], view.TextureHandles);
}
[Fact]
public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference()
{
Type[] owners =
[
typeof(PaperdollFramePresenter),
typeof(RetailPaperdollFrameView),
typeof(PaperdollInventoryVisibility),
typeof(LivePaperdollEntityLookup),
typeof(RetailPaperdollDollFactory),
typeof(RetailPaperdollPoseApplicator),
];
foreach (Type owner in owners)
{
FieldInfo[] fields = owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
Assert.DoesNotContain(
fields,
field => field.FieldType == typeof(AcDream.App.UI.RetailUiRuntime));
Assert.DoesNotContain(
fields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
}
}
[Fact]
public void RetailFactory_MissingOrEmptyCurrentPlayerRetriesWithoutApplyingPose()
{
var entities = new RecordingEntityLookup();
var identity = new LocalPlayerIdentityState { ServerGuid = 7u };
var pose = new RecordingPoseApplicator();
var factory = new RetailPaperdollDollFactory(entities, identity, pose);
Assert.False(factory.TryBuild(out WorldEntity? missing));
Assert.Null(missing);
entities.Entities[7u] = CreateDoll();
Assert.False(factory.TryBuild(out WorldEntity? empty));
Assert.Null(empty);
Assert.Empty(pose.Applications);
}
[Fact]
public void RetailFactory_ResolvesCurrentIdentityAndClonesLiveAppearance()
{
var firstMesh = new MeshRef(0x01000001u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint> { [2] = 0x08000001u },
};
var secondMesh = new MeshRef(
0x01000002u,
Matrix4x4.CreateTranslation(1f, 2f, 3f));
var first = CreatePlayer(0x02000001u, firstMesh);
var second = CreatePlayer(0x02000002u, secondMesh);
var entities = new RecordingEntityLookup
{
Entities =
{
[10u] = first,
[20u] = second,
},
};
var identity = new LocalPlayerIdentityState { ServerGuid = 10u };
var pose = new RecordingPoseApplicator();
var factory = new RetailPaperdollDollFactory(entities, identity, pose);
Assert.True(factory.TryBuild(out WorldEntity? firstDoll));
identity.ServerGuid = 20u;
Assert.True(factory.TryBuild(out WorldEntity? secondDoll));
Assert.NotNull(firstDoll);
Assert.NotNull(secondDoll);
Assert.Equal(0x02000001u, firstDoll.SourceGfxObjOrSetupId);
Assert.Equal(0x02000002u, secondDoll.SourceGfxObjOrSetupId);
Assert.NotSame(first.MeshRefs, firstDoll.MeshRefs);
Assert.Equal(firstMesh.GfxObjId, firstDoll.MeshRefs[0].GfxObjId);
Assert.Same(firstMesh.SurfaceOverrides, firstDoll.MeshRefs[0].SurfaceOverrides);
Assert.Equal(
[(firstDoll, 0x02000001u), (secondDoll, 0x02000002u)],
pose.Applications);
Assert.Equal([10u, 20u], entities.RequestedGuids);
}
private static WorldEntity CreatePlayer(uint setupId, MeshRef mesh) => new()
{
Id = setupId,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = new List<MeshRef> { mesh },
};
private sealed class RecordingRenderer : IPaperdollDollRenderer
{
public uint TextureHandle { get; init; }
public List<WorldEntity?> Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count;
public void SetDoll(WorldEntity? doll) => Dolls.Add(doll);
public uint Render(int width, int height)
{
RenderSizes.Add((width, height));
return TextureHandle;
}
}
private sealed class RecordingView : IPaperdollFrameView
{
public bool Visible { get; init; } = true;
public int Width { get; init; } = 100;
public int Height { get; init; } = 120;
public List<uint> TextureHandles { get; } = [];
public bool TryGetVisibleSize(out int width, out int height)
{
width = Width;
height = Height;
return Visible;
}
public void SetTextureHandle(uint textureHandle) =>
TextureHandles.Add(textureHandle);
}
private sealed class RecordingFactory : IPaperdollDollFactory
{
public bool CanBuild { get; init; } = true;
public WorldEntity? Doll { get; init; }
public int BuildCount { get; private set; }
public bool TryBuild(out WorldEntity? doll)
{
BuildCount++;
doll = Doll;
return CanBuild;
}
}
private sealed class RecordingEntityLookup : IPaperdollEntityLookup
{
public Dictionary<uint, WorldEntity> Entities { get; } = [];
public List<uint> RequestedGuids { get; } = [];
public bool TryGet(uint serverGuid, out WorldEntity player)
{
RequestedGuids.Add(serverGuid);
return Entities.TryGetValue(serverGuid, out player!);
}
}
private sealed class RecordingPoseApplicator : IPaperdollPoseApplicator
{
public List<(WorldEntity Doll, uint SetupId)> Applications { get; } = [];
public void Apply(WorldEntity doll, uint setupId) =>
Applications.Add((doll, setupId));
}
}

View file

@ -0,0 +1,321 @@
using System.Globalization;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderFrameDiagnosticsControllerTests
{
private static readonly RenderFrameOutcome Outcome = new(
new WorldRenderFrameOutcome(
VisibleLandblocks: 17,
TotalLandblocks: 43,
NormalWorldDrawn: true),
new PrivatePresentationFrameOutcome(
PortalViewportDrawn: false,
ScreenshotCaptured: true));
private static readonly RenderFrameTitleFacts TitleFacts = new(
EntityCount: 3721,
AnimatedEntityCount: 226,
Calendar: new DerethDateTime.Calendar(
117,
DerethDateTime.MonthName.Leafcull,
22,
DerethDateTime.HourName.DawnsongAndHalf),
DayFraction: 0.3125);
[Fact]
public void Snapshot_StartsAtTheAcceptedRuntimeDefaults()
{
var harness = new Harness(resourceDump: false);
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
Assert.Equal(60.0, harness.Controller.Snapshot.Fps);
Assert.Equal(16.7, harness.Controller.Snapshot.FrameMilliseconds);
Assert.Empty(harness.Calls);
}
[Fact]
public void Publish_BelowHalfSecondDoesNotSampleOrPublish()
{
var harness = new Harness(resourceDump: true);
harness.Publish(0.499999);
Assert.Empty(harness.Calls);
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
}
[Fact]
public void Publish_AtHalfSecondPublishesTitleThenResourcesThenSnapshot()
{
using var culture = new CultureScope(CultureInfo.InvariantCulture);
var harness = new Harness(resourceDump: true);
harness.Publish(0.2);
harness.Publish(0.3);
Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
Assert.Equal(
"acdream | 4 fps | 250.0 ms | lb 17/43 | ent 3721/anim 226 | "
+ "PY117 Leafcull 22 DawnsongAndHalf (df=0.3125)",
harness.Title);
Assert.Equal(
new RenderFrameDiagnosticsSnapshot(
Fps: 4.0,
FrameMilliseconds: 250.0,
VisibleLandblocks: 17,
TotalLandblocks: 43,
EntityCount: 3721,
AnimatedEntityCount: 226),
harness.Controller.Snapshot);
Assert.Equal(
RenderFrameDiagnosticsController.FormatGpuStream(ResourceFacts),
harness.Line);
}
[Fact]
public void Publish_WhenResourceDumpIsOffDoesNotCaptureResources()
{
var harness = new Harness(resourceDump: false);
harness.Publish(0.5);
Assert.Equal(["facts", "title"], harness.Calls);
Assert.Null(harness.Line);
}
[Fact]
public void Publish_DiscardsCadenceOvershootAfterSuccessfulPublication()
{
var harness = new Harness(resourceDump: false);
harness.Publish(0.6);
harness.Calls.Clear();
harness.Publish(0.4);
Assert.Empty(harness.Calls);
Assert.Equal(1.0 / 0.6, harness.Controller.Snapshot.Fps, precision: 10);
harness.Publish(0.1);
Assert.Equal(["facts", "title"], harness.Calls);
Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10);
Assert.Equal(250.0, harness.Controller.Snapshot.FrameMilliseconds, precision: 10);
}
[Fact]
public void TitleFailure_DoesNotCaptureResourcesPublishSnapshotOrResetWindow()
{
var harness = new Harness(resourceDump: true)
{
TitleFailure = new InvalidOperationException("title"),
};
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
() => harness.Publish(0.5));
Assert.Same(harness.TitleFailure, actual);
Assert.Equal(["facts", "title"], harness.Calls);
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
harness.TitleFailure = null;
harness.Calls.Clear();
harness.Publish(0.0);
Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10);
Assert.Equal(250.0, harness.Controller.Snapshot.FrameMilliseconds, precision: 10);
}
[Fact]
public void ResourceLogFailure_LeavesPublishedCacheAndCadenceUntouched()
{
var harness = new Harness(resourceDump: true)
{
LogFailure = new InvalidOperationException("log"),
};
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
() => harness.Publish(0.5));
Assert.Same(harness.LogFailure, actual);
Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
Assert.NotNull(harness.Title);
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
harness.LogFailure = null;
harness.Calls.Clear();
harness.Publish(0.0);
Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10);
}
[Fact]
public void Constructor_RequiresEveryActiveNarrowSeam()
{
var facts = new FixedTitleFactsSource([], TitleFacts);
var title = new RecordingTitleSink([]);
var log = new RecordingLog([]);
var resources = new RecordingResources([], ResourceFacts);
Assert.Throws<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
null!, title, log, false));
Assert.Throws<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
facts, null!, log, false));
Assert.Throws<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
facts, title, null!, false));
Assert.Throws<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
facts, title, log, true));
_ = new RenderFrameDiagnosticsController(facts, title, log, false, resources: null);
_ = new RenderFrameDiagnosticsController(facts, title, log, true, resources);
}
[Fact]
public void Controller_RetainsOnlyNarrowTypedSeamsAndValueState()
{
FieldInfo[] fields = typeof(RenderFrameDiagnosticsController).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
Assert.DoesNotContain(fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType));
Assert.DoesNotContain(fields, field =>
field.FieldType.FullName?.Contains("Silk.NET.Windowing.IWindow", StringComparison.Ordinal) == true);
Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameTitleFactsSource));
Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameTitleSink));
Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameDiagnosticLog));
Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameResourceDiagnosticsSource));
Assert.True(typeof(IRenderFrameDiagnosticsPhase).IsAssignableFrom(
typeof(RenderFrameDiagnosticsController)));
}
[Fact]
public void GpuStreamFormatter_PreservesTheAcceptedFieldOrderAndShape()
{
string actual = RenderFrameDiagnosticsController.FormatGpuStream(ResourceFacts);
Assert.Equal(
"[gpu-stream] emit=1 particles=2 bindings=3 wbSets=4 cellSets=5 "
+ "particleSets=6/7B uiBytes=8 portalBytes=9 clipSets=10 terrainBuffers=11 "
+ "lightBuffers=12 mesh=13/14/15 meshEst=16 meshUpload=17/18 "
+ "uploadFrame=19/20/21/22/23 bufferFrame=24/25/26/27 "
+ "staging=28/29/True/30 cpuMesh=31/32 mipmapFrame=33/34 meshCap=35 "
+ "meshPhys=36/True gpuTrack=57/58/59 managed=55/56 ownedTextures=37/38 "
+ "particleTextures=39/40/41/42/43 compositeCache=44/45/46 "
+ "compositeAtlas=47/48 compositeUpload=49/50/51",
actual);
}
private sealed class Harness
{
public readonly List<string> Calls = [];
public readonly RenderFrameDiagnosticsController Controller;
private readonly RecordingTitleSink _title;
private readonly RecordingLog _log;
public Harness(bool resourceDump)
{
_title = new RecordingTitleSink(Calls);
_log = new RecordingLog(Calls);
Controller = new RenderFrameDiagnosticsController(
new FixedTitleFactsSource(Calls, TitleFacts),
_title,
_log,
resourceDump,
new RecordingResources(Calls, ResourceFacts));
}
public Exception? TitleFailure
{
get => _title.Failure;
set => _title.Failure = value;
}
public Exception? LogFailure
{
get => _log.Failure;
set => _log.Failure = value;
}
public string? Title => _title.Title;
public string? Line => _log.Line;
public void Publish(double deltaSeconds) => Controller.Publish(
new RenderFrameInput(deltaSeconds, 1920, 1080),
Outcome);
}
private sealed class FixedTitleFactsSource(
List<string> calls,
RenderFrameTitleFacts facts) : IRenderFrameTitleFactsSource
{
public RenderFrameTitleFacts Capture()
{
calls.Add("facts");
return facts;
}
}
private sealed class RecordingTitleSink(List<string> calls) : IRenderFrameTitleSink
{
public Exception? Failure { get; set; }
public string? Title { get; private set; }
public void SetTitle(string title)
{
calls.Add("title");
if (Failure is not null)
throw Failure;
Title = title;
}
}
private sealed class RecordingResources(
List<string> calls,
RenderFrameResourceDiagnosticsSnapshot snapshot)
: IRenderFrameResourceDiagnosticsSource
{
public RenderFrameResourceDiagnosticsSnapshot Capture()
{
calls.Add("resources");
return snapshot;
}
}
private sealed class RecordingLog(List<string> calls) : IRenderFrameDiagnosticLog
{
public Exception? Failure { get; set; }
public string? Line { get; private set; }
public void WriteLine(string message)
{
calls.Add("log");
if (Failure is not null)
throw Failure;
Line = message;
}
}
private sealed class CultureScope : IDisposable
{
private readonly CultureInfo _prior = CultureInfo.CurrentCulture;
public CultureScope(CultureInfo culture) => CultureInfo.CurrentCulture = culture;
public void Dispose() => CultureInfo.CurrentCulture = _prior;
}
private static readonly RenderFrameResourceDiagnosticsSnapshot ResourceFacts = new(
new VfxStreamResourceDiagnostics(1, 2, 3),
new DynamicBufferResourceDiagnostics(4, 5, 6, 7, 8, 9, 10, 11, 12),
new MeshStreamResourceDiagnostics(
13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
24, 25, 26, 27, 28, 29, true, 30, 31, 32, 33, 34, 35, 36, true),
new TextureStreamResourceDiagnostics(
37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51),
new ProcessResourceDiagnostics(55, 56, 57, 58, 59));
}

View file

@ -0,0 +1,66 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class RollingTimingSampleWindowTests
{
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Constructor_RejectsNonPositiveCapacity(int capacity)
{
Assert.Throws<ArgumentOutOfRangeException>(
() => new RollingTimingSampleWindow(capacity));
}
[Fact]
public void Snapshot_EmptyOrNonPositiveSamplesReturnsZeroes()
{
var samples = new RollingTimingSampleWindow(4);
samples.PushHundredthsMicroseconds(0);
samples.PushHundredthsMicroseconds(-10);
Assert.Equal(default, samples.Snapshot());
}
[Fact]
public void Snapshot_UsesTheAcceptedUpperMedianAndP95Rules()
{
var samples = new RollingTimingSampleWindow(8);
samples.PushHundredthsMicroseconds(100);
samples.PushHundredthsMicroseconds(200);
RollingTimingPercentiles snapshot = samples.Snapshot();
Assert.Equal(200, snapshot.MedianHundredthsMicroseconds);
Assert.Equal(200, snapshot.Percentile95HundredthsMicroseconds);
}
[Fact]
public void Push_WrapsAndRetainsOnlyTheNewestCapacitySamples()
{
var samples = new RollingTimingSampleWindow(3);
samples.PushHundredthsMicroseconds(100);
samples.PushHundredthsMicroseconds(200);
samples.PushHundredthsMicroseconds(300);
samples.PushHundredthsMicroseconds(400);
RollingTimingPercentiles snapshot = samples.Snapshot();
Assert.Equal(3, samples.Capacity);
Assert.Equal(300, snapshot.MedianHundredthsMicroseconds);
Assert.Equal(400, snapshot.Percentile95HundredthsMicroseconds);
}
[Fact]
public void PushStopwatchTicks_UsesHundredthsOfAMicrosecondUnits()
{
var samples = new RollingTimingSampleWindow(1);
samples.PushStopwatchTicks(System.Diagnostics.Stopwatch.Frequency);
Assert.Equal(
new RollingTimingPercentiles(100_000_000, 100_000_000),
samples.Snapshot());
}
}

View file

@ -0,0 +1,292 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.Core.Vfx;
namespace AcDream.App.Tests.Rendering;
public sealed class WorldRenderDiagnosticsTests
{
[Fact]
public void GlTripwire_DisabledPerformsNoGlRead()
{
var gl = new RecordingGlStateReader();
var log = new RecordingLog();
var diagnostics = new WorldRenderDiagnostics(gl, log);
diagnostics.EmitGlStateTripwireIfChanged(enabled: false);
Assert.Equal(0, gl.StateCaptureCount);
Assert.Empty(log.Messages);
}
[Fact]
public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount()
{
var gl = new RecordingGlStateReader
{
State = State(depthFunction: 0x201),
};
var log = new RecordingLog();
var diagnostics = new WorldRenderDiagnostics(gl, log);
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
gl.State = State(depthFunction: 0x203);
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
Assert.Equal(3, gl.StateCaptureCount);
Assert.Collection(
log.Messages,
first =>
{
Assert.StartsWith("[gl-state] frame=1 stable=0", first);
Assert.Contains("dfunc=0x201", first);
},
changed =>
{
Assert.StartsWith("[gl-state] frame=3 stable=1", changed);
Assert.Contains("dfunc=0x203", changed);
});
}
[Fact]
public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates()
{
var gl = new RecordingGlStateReader
{
Scissor = new RenderGlScissorSnapshot(
true,
new IntRenderRectangle(1, 2, 3, 4)),
};
var log = new RecordingLog();
var diagnostics = new WorldRenderDiagnostics(gl, log);
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
gl.Scissor = new RenderGlScissorSnapshot(
true,
new IntRenderRectangle(5, 6, 7, 8));
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
Assert.Equal(3, gl.ScissorCaptureCount);
Assert.Collection(
log.Messages,
first => Assert.StartsWith("[clip-route-scis] n=1", first),
changed => Assert.StartsWith("[clip-route-scis] n=3", changed));
}
[Fact]
public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners()
{
FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Type[] forbidden =
[
typeof(GameWindow),
typeof(PortalVisibilityFrame),
typeof(ClipFrameAssembly),
typeof(InteriorEntityPartition.Result),
typeof(AcDream.Core.Physics.PhysicsEngine),
typeof(CameraController),
typeof(CellVisibility),
];
foreach (Type type in forbidden)
Assert.DoesNotContain(fields, field => field.FieldType == type);
Assert.DoesNotContain(
fields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
}
[Fact]
public void SilkGlReader_ConsumesEnteringErrorBeforeStateQueries()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"WorldRenderDiagnostics.cs"));
int captureStart = source.IndexOf(
"public RenderGlStateSnapshot CaptureState()",
StringComparison.Ordinal);
int error = source.IndexOf("_gl.GetError()", captureStart, StringComparison.Ordinal);
int depth = source.IndexOf(
"_gl.IsEnabled(EnableCap.DepthTest)",
captureStart,
StringComparison.Ordinal);
Assert.True(captureStart >= 0);
Assert.True(error > captureStart);
Assert.True(depth > error);
}
[Fact]
public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
{
var log = new ThrowOnceLog();
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
var facts = new TerrainRenderDiagnosticFacts(3, 5, 8);
diagnostics.BeginTerrainDraw();
diagnostics.EndTerrainDraw();
Assert.Throws<InvalidOperationException>(() =>
diagnostics.PublishTerrainDiagnostics(facts));
diagnostics.BeginTerrainDraw();
diagnostics.EndTerrainDraw();
diagnostics.PublishTerrainDiagnostics(facts);
Assert.Single(log.Messages);
Assert.Contains("draws=3/frame", log.Messages[0]);
Assert.Contains("visible=3", log.Messages[0]);
Assert.Contains("loaded=5", log.Messages[0]);
Assert.Contains("capacity=8", log.Messages[0]);
}
[Fact]
public void RenderSignature_LogsOnlyChangesAndCarriesStableCount()
{
var log = new RecordingLog();
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
EmitMinimalSignature(diagnostics, "flat");
EmitMinimalSignature(diagnostics, "flat");
EmitMinimalSignature(diagnostics, "pview");
Assert.Collection(
log.Messages,
first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first),
changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed));
}
[Fact]
public void OutStageParticleProbe_IsDisabledWithoutEnumerationAndSuppressesDuplicates()
{
var log = new RecordingLog();
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
var particles = new ParticleSystem(new EmitterDescRegistry());
diagnostics.EmitOutStageParticles(false, particles, new HashSet<uint>());
diagnostics.EmitOutStageParticles(true, particles, new HashSet<uint>());
diagnostics.EmitOutStageParticles(true, particles, new HashSet<uint>());
Assert.Single(log.Messages);
Assert.Equal(
"[outstage-pt] ids=0 attachedEmitters=0 matched=0 unattached=0 matchedIds=[] unmatchedIds=[]",
log.Messages[0]);
}
private static RenderGlStateSnapshot State(int depthFunction) => new(
DepthTest: true,
DepthWrite: true,
DepthFunction: depthFunction,
Blend: false,
BlendSource: 1,
BlendDestination: 0,
CullFace: true,
CullMode: 0x405,
FrontFace: 0x900,
Scissor: false,
ScissorBox: new IntRenderRectangle(0, 0, 1280, 720),
Viewport: new IntRenderRectangle(0, 0, 1280, 720),
DrawFramebuffer: 0,
AlphaToCoverage: false,
Stencil: false,
ClipBits: 0,
Error: 0);
private sealed class RecordingGlStateReader : IRenderGlStateReader
{
public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201);
public RenderGlScissorSnapshot Scissor { get; set; }
public int StateCaptureCount { get; private set; }
public int ScissorCaptureCount { get; private set; }
public RenderGlStateSnapshot CaptureState()
{
StateCaptureCount++;
return State;
}
public RenderGlScissorSnapshot CaptureScissor()
{
ScissorCaptureCount++;
return Scissor;
}
}
private sealed class RecordingLog : IRenderFrameDiagnosticLog
{
public List<string> Messages { get; } = [];
public void WriteLine(string message) => Messages.Add(message);
}
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
{
private bool _throw = true;
public List<string> Messages { get; } = [];
public void WriteLine(string message)
{
if (_throw)
{
_throw = false;
throw new InvalidOperationException("diagnostic sink failed");
}
Messages.Add(message);
}
}
private static void EmitMinimalSignature(
WorldRenderDiagnostics diagnostics,
string branch) =>
diagnostics.EmitRenderSignatureIfChanged(
enabled: true,
branch: branch,
clipRoot: null,
viewerRoot: null,
playerRoot: null,
viewerCellId: 0,
playerCellId: 0,
playerIndoorGate: false,
cameraInsideCell: false,
renderSkyGate: false,
drawSkyThisFrame: false,
terrainDrawn: false,
terrainClipMode: TerrainClipMode.Skip,
skyDrawn: false,
depthClear: false,
outdoorSceneryDrawn: false,
outdoorPortalDrawn: false,
outdoorRootObjectCount: 0,
liveDynamicDrawnCount: 0,
sceneParticles: "none",
portalFrame: null,
clipAssembly: null,
drawableCells: null,
partition: null,
exteriorPortalFrame: null,
exteriorClipAssembly: null,
exteriorDrawableCells: null,
exteriorPartition: null,
cameraPosition: Vector3.Zero,
playerPosition: Vector3.Zero);
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.");
}
}