refactor(render): extract frame presentation diagnostics
This commit is contained in:
parent
7e4cfb37c3
commit
733126a272
20 changed files with 3767 additions and 1021 deletions
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
333
tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs
Normal file
333
tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
292
tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
Normal file
292
tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue