acdream/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

523 lines
19 KiB
C#

using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Combat;
using AcDream.App.Composition;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
using AcDream.UI.ImGui;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Tests.Composition;
public sealed class SettingsDevToolsCompositionTests
{
[Fact]
public void DisabledFrontendAppliesSettingsAndAcquiresNothingOptional()
{
using var fixture = new Fixture(enabled: false);
SettingsDevToolsResult result = fixture.Compose();
Assert.Null(result.DevTools);
Assert.False(
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
Assert.Equal(1, fixture.Startup.DisplayCalls);
Assert.Equal(1, fixture.Startup.AudioCalls);
Assert.Equal(0, fixture.Factory.Calls);
Assert.Equal(
[SettingsDevToolsCompositionPoint.SettingsApplied],
fixture.Points);
}
[Fact]
public void UnsupportedInputDisablesBeforeAcquiringFrontendResources()
{
using var fixture = new Fixture(inputSupported: false);
SettingsDevToolsResult result = fixture.Compose();
Assert.Null(result.DevTools);
Assert.Null(fixture.Factory.Input);
Assert.Null(fixture.Factory.Bootstrap);
Assert.Equal(1, fixture.Factory.Calls);
}
[Fact]
public void EnabledFrontendPublishesOneCompleteOwnerAfterEveryEdge()
{
using var fixture = new Fixture();
SettingsDevToolsResult result = fixture.Compose();
Assert.Same(fixture.Publication.Owner, result.DevTools);
Assert.Equal(
Enum.GetValues<SettingsDevToolsCompositionPoint>(),
fixture.Points);
Assert.True(fixture.Factory.Input!.Activated);
Assert.Equal(4, fixture.Factory.Backend!.LayoutCalls);
Assert.Equal(1, fixture.Publication.PublishCalls);
}
[Theory]
[MemberData(nameof(OptionalFailurePoints))]
public void OptionalPrefixFailureDisablesOnlyAfterCompleteReverseCleanup(
int pointValue)
{
var point = (SettingsDevToolsCompositionPoint)pointValue;
using var fixture = new Fixture(failurePoint: point);
SettingsDevToolsResult result = fixture.Compose();
Assert.Null(result.DevTools);
Assert.Null(fixture.Publication.Owner);
Assert.Equal(
Enum.GetValues<SettingsDevToolsCompositionPoint>()
.TakeWhile(candidate => candidate <= point),
fixture.Points);
if (fixture.Factory.Input is { } input)
Assert.True(input.IsDisposalComplete);
if (fixture.Factory.Bootstrap is { } bootstrap)
Assert.Equal(1, bootstrap.DisposeCalls);
if (fixture.Factory.Backend is { } backend)
Assert.Equal(1, backend.DisposeCalls);
}
public static TheoryData<int> OptionalFailurePoints()
{
var data = new TheoryData<int>();
foreach (SettingsDevToolsCompositionPoint point in
Enum.GetValues<SettingsDevToolsCompositionPoint>())
{
if (point is SettingsDevToolsCompositionPoint.SettingsApplied
or SettingsDevToolsCompositionPoint.DevToolsPublished)
{
continue;
}
data.Add((int)point);
}
return data;
}
[Fact]
public void FailureAfterPublicationPropagatesToLifetimeOwner()
{
using var fixture = new Fixture(
failurePoint: SettingsDevToolsCompositionPoint.DevToolsPublished);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.NotNull(fixture.Publication.Owner);
Assert.False(fixture.Publication.Owner!.IsDisposalComplete);
}
[Fact]
public void CleanupFailurePropagatesAndRetrySkipsCompletedOperations()
{
using var fixture = new Fixture(
failurePoint: SettingsDevToolsCompositionPoint.InitialLayoutApplied,
backendDisposeFailures: 1);
var failure = Assert.Throws<CompositionAcquisitionException>(fixture.Compose);
Assert.False(failure.IsCleanupComplete);
Assert.Equal(1, fixture.Factory.Backend!.DisposeCalls);
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
failure.RetryCleanup();
Assert.True(failure.IsCleanupComplete);
Assert.Equal(2, fixture.Factory.Backend.DisposeCalls);
Assert.Equal(1, fixture.Factory.Input.DisposeCalls);
}
[Fact]
public void BootstrapConstructionWithoutCleanupOwnerAbortsHostStartup()
{
var bootstrapFailure = (ImGuiBootstrapperConstructionException)
Activator.CreateInstance(
typeof(ImGuiBootstrapperConstructionException),
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
args: [new InvalidOperationException("partial Silk construction")],
culture: null)!;
using var fixture = new Fixture(bootstrapFailure: bootstrapFailure);
Assert.Throws<ImGuiBootstrapperConstructionException>(fixture.Compose);
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
Assert.Null(fixture.Publication.Owner);
Assert.DoesNotContain(
fixture.Logs,
message => message.Contains("devtools disabled", StringComparison.Ordinal));
}
[Fact]
public void GameWindowUsesProductionPhaseAndDoesNotRetainHostClosures()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new SettingsDevToolsCompositionPhase(", source,
StringComparison.Ordinal);
Assert.DoesNotContain("new AcDream.UI.ImGui.ImGuiBootstrapper(", source,
StringComparison.Ordinal);
Assert.DoesNotContain("getPlayerPosition: () => GetDebugPlayerPosition()", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_runtimeSettings.ApplyStartup(", source,
StringComparison.Ordinal);
}
private sealed class Fixture : IDisposable
{
private readonly SettingsDevToolsCompositionPoint? _failurePoint;
private readonly InputDispatcher _dispatcher;
private readonly FrameProfiler _profiler = new();
public Fixture(
bool enabled = true,
SettingsDevToolsCompositionPoint? failurePoint = null,
int backendDisposeFailures = 0,
bool inputSupported = true,
Exception? bootstrapFailure = null)
{
_failurePoint = failurePoint;
Factory = new Factory(
backendDisposeFailures,
inputSupported,
bootstrapFailure);
Publication = new Publication();
Startup = new StartupTarget();
Settings = new RuntimeSettingsController(
new Storage(),
QualitySettings.From,
static _ => { });
_dispatcher = InputDispatcher.CreateDetached(
new KeyboardSource(),
new MouseSource(),
new KeyBindings());
_dispatcher.Attach();
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
Host = new HostInputCameraResult(
null!,
null!,
null!,
null,
null,
null,
_dispatcher,
camera,
null);
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject(
typeof(ContentEffectsAudioResult));
Dependencies = new SettingsDevToolsDependencies(
DispatchProxy.Create<IView, ViewProxy>(),
Settings,
Startup,
new HostQuiescenceGate(),
GameRuntimeTestFactory.Create(),
enabled
? new SettingsDevToolsOptionalDependencies(
new Facts(),
new KeyBindingTarget(),
new DeferredCanonicalWorldEntityCountSource(),
new DeferredRenderFrameDiagnosticsSource(),
new DeferredDevToolsPlayerModeCommands())
: null,
new RuntimeDiagnosticCommandSlot(),
new CombatFeedbackSlot(),
new KeyBindings(),
_profiler,
new FramebufferResizeController(new ViewportAspectState()),
Logs.Add);
}
public List<SettingsDevToolsCompositionPoint> Points { get; } = [];
public List<string> Logs { get; } = [];
public Factory Factory { get; }
public Publication Publication { get; }
public StartupTarget Startup { get; }
public RuntimeSettingsController Settings { get; }
public SettingsDevToolsDependencies Dependencies { get; }
public HostInputCameraResult Host { get; }
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
public ContentEffectsAudioResult Content { get; }
public SettingsDevToolsResult Compose() =>
new SettingsDevToolsCompositionPhase(
Dependencies,
Publication,
Factory,
point =>
{
Points.Add(point);
if (point == _failurePoint)
throw new InvalidOperationException($"fault at {point}");
}).Compose(Platform, Host, Content);
public void Dispose()
{
Dependencies.Runtime.Dispose();
Publication.Owner?.Dispose();
_dispatcher.Dispose();
_profiler.Dispose();
}
}
private sealed class Publication : IGameWindowSettingsDevToolsPublication
{
public DevToolsCompositionOwner? Owner { get; private set; }
public int PublishCalls { get; private set; }
public void PublishDevTools(DevToolsCompositionOwner value)
{
if (Owner is not null)
throw new InvalidOperationException("duplicate publication");
Owner = value;
PublishCalls++;
}
}
private sealed class Factory(
int backendDisposeFailures,
bool inputSupported,
Exception? bootstrapFailure)
: ISettingsDevToolsCompositionFactory
{
public int Calls { get; private set; }
public InputContext? Input { get; private set; }
public Bootstrap? Bootstrap { get; private set; }
public Backend? Backend { get; private set; }
public bool IsSupported(IInputContext input)
{
Calls++;
return inputSupported;
}
public IDevToolsInputContext CreateInputContext(
IInputContext input,
HostQuiescenceGate quiescence)
{
Calls++;
return Input = new InputContext();
}
public IImGuiBootstrapper CreateBootstrap(
GL gl,
IView window,
IInputContext input)
{
Calls++;
if (bootstrapFailure is not null)
throw bootstrapFailure;
return Bootstrap = new Bootstrap();
}
public ImGuiPanelHost CreatePanelHost()
{
Calls++;
return new ImGuiPanelHost();
}
public IDevToolsFrameBackend CreateBackend(
IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels)
{
Calls++;
return Backend = new Backend(
(Bootstrap)bootstrap,
backendDisposeFailures);
}
}
private sealed class InputContext : IDevToolsInputContext
{
public bool Activated { get; private set; }
public int DisposeCalls { get; private set; }
public bool IsDisposalComplete { get; private set; }
public nint Handle => 0;
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
public IReadOnlyList<IKeyboard> Keyboards { get; } = [];
public IReadOnlyList<IMouse> Mice { get; } = [];
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
#pragma warning disable CS0067
public event Action<IInputDevice, bool>? ConnectionChanged;
#pragma warning restore CS0067
public void Activate() => Activated = true;
public void Deactivate() => Activated = false;
public void Dispose()
{
DisposeCalls++;
Activated = false;
IsDisposalComplete = true;
}
}
private sealed class Bootstrap : IImGuiBootstrapper
{
public int DisposeCalls { get; private set; }
public void BeginFrame(float deltaSeconds) { }
public void Render() { }
public void AbortFrame() { }
public void Dispose() => DisposeCalls++;
}
private sealed class Backend(
Bootstrap bootstrap,
int remainingDisposeFailures) : IDevToolsFrameBackend
{
private int _remainingDisposeFailures = remainingDisposeFailures;
public int DisposeCalls { get; private set; }
public int LayoutCalls { get; private set; }
public void BeginFrame(float deltaSeconds) { }
public void AbortFrame() { }
public bool BeginMainMenuBar() => false;
public void EndMainMenuBar() { }
public bool BeginMenu(string label) => false;
public void EndMenu() { }
public bool MenuItem(string label, string? shortcut = null, bool selected = false) => false;
public void Separator() { }
public void RenderPanels(AcDream.UI.Abstractions.PanelContext context) { }
public void RenderDrawData() { }
public void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition) => LayoutCalls++;
public void Dispose()
{
DisposeCalls++;
if (_remainingDisposeFailures-- > 0)
throw new InvalidOperationException("backend cleanup failed");
bootstrap.Dispose();
}
}
private sealed class StartupTarget : IRuntimeSettingsStartupTarget
{
public int DisplayCalls { get; private set; }
public int AudioCalls { get; private set; }
public void ApplyDisplay(DisplaySettings display) => DisplayCalls++;
public void ApplyAudio(AudioSettings audio) => AudioCalls++;
}
private sealed class Storage : IRuntimeSettingsStorage
{
public SettingsStore? LayoutStore => null;
public string Location => "memory://settings";
public DisplaySettings LoadDisplay() => DisplaySettings.Default;
public AudioSettings LoadAudio() => AudioSettings.Default;
public GameplaySettings LoadGameplay() => GameplaySettings.Default;
public ChatSettings LoadChat() => ChatSettings.Default;
public CharacterSettings LoadCharacter(string toonKey) => CharacterSettings.Default;
public void SaveDisplay(DisplaySettings display) { }
public void SaveAudio(AudioSettings audio) { }
public void SaveGameplay(GameplaySettings gameplay) { }
public void SaveChat(ChatSettings chat) { }
public void SaveCharacter(string toonKey, CharacterSettings character) { }
}
private sealed class KeyBindingTarget : IRuntimeKeyBindingTarget
{
public void Apply(KeyBindings bindings) { }
}
private sealed class Facts : IDevToolsRuntimeFacts
{
public Vector3 PlayerPosition => default;
public float PlayerHeadingDegrees => 0;
public uint PlayerCellId => 0;
public bool PlayerOnGround => false;
public bool InPlayerMode => false;
public bool InFlyMode => false;
public float VerticalVelocity => 0;
public int EntityCount => 0;
public int AnimatedCount => 0;
public int VisibleLandblocks => 0;
public int TotalLandblocks => 0;
public int ShadowObjectCount => 0;
public float NearestObjectDistance => float.PositiveInfinity;
public string NearestObjectLabel => "-";
public bool Colliding => false;
public bool CollisionWireframesVisible => false;
public int StreamingRadius => 0;
public float MouseSensitivity => 1;
public float ChaseDistance => 0;
public bool RmbOrbitHeld => false;
public string HourName => "0";
public float DayFraction => 0;
public string Weather => "Clear";
public int ActiveLights => 0;
public int RegisteredLights => 0;
public int ParticleCount => 0;
public float Fps => 60;
public float FrameMilliseconds => 16.7f;
}
private sealed class KeyboardSource : IKeyboardSource
{
#pragma warning disable CS0067
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
#pragma warning restore CS0067
public bool IsHeld(Key key) => false;
public ModifierMask CurrentModifiers => ModifierMask.None;
}
private sealed class MouseSource : IMouseSource
{
#pragma warning disable CS0067
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
}
public class ViewProxy : DispatchProxy
{
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod?.Name == "get_Size")
return new Vector2D<int>(1280, 720);
Type type = targetMethod?.ReturnType ?? typeof(void);
if (type == typeof(void))
return null;
return type.IsValueType ? Activator.CreateInstance(type) : null;
}
}
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.");
}
}