From cd7b519f78ae9cca5ad29357c5c67c3e8104e5ec Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 16:11:34 +0200 Subject: [PATCH] refactor(app): compose settings and developer tools --- .../Combat/LiveCombatAttackOperations.cs | 21 +- .../SettingsDevToolsComposition.cs | 631 ++++++++++++++++++ src/AcDream.App/Input/PlayerModeController.cs | 4 +- .../Input/QuiescentInputContext.cs | 9 +- .../Rendering/DevToolsFramePresenter.cs | 45 +- .../Rendering/DevToolsRuntimeSources.cs | 289 ++++++++ .../Rendering/FramebufferResizeController.cs | 25 + src/AcDream.App/Rendering/GameWindow.cs | 383 +++-------- .../RenderFrameDiagnosticsController.cs | 4 +- .../Settings/RuntimeKeyBindingTarget.cs | 45 ++ .../Settings/RuntimeSettingsController.cs | 36 + .../Panels/Chat/ChatVM.cs | 11 +- .../Panels/Debug/DebugVM.cs | 69 +- src/AcDream.UI.ImGui/ImGuiBootstrapper.cs | 42 +- .../Combat/CombatFeedbackSlotTests.cs | 59 ++ .../SettingsDevToolsCompositionTests.cs | 520 +++++++++++++++ .../Rendering/DevToolsFramePresenterTests.cs | 1 + .../Rendering/DevToolsRuntimeSourcesTests.cs | 98 +++ .../FramebufferResizeControllerTests.cs | 16 + .../GameWindowRenderLeafCompositionTests.cs | 12 +- .../GameWindowSlice8BoundaryTests.cs | 31 +- .../RuntimeSettingsControllerTests.cs | 25 + .../Panels/Chat/ChatVMLastTellSenderTests.cs | 14 + .../Panels/Debug/DebugVMTests.cs | 16 + 24 files changed, 2073 insertions(+), 333 deletions(-) create mode 100644 src/AcDream.App/Composition/SettingsDevToolsComposition.cs create mode 100644 src/AcDream.App/Rendering/DevToolsRuntimeSources.cs create mode 100644 src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs create mode 100644 tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs create mode 100644 tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs index c37f4328..1ccf7994 100644 --- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -33,8 +33,25 @@ internal interface ICombatFeedbackSink internal sealed class CombatFeedbackSlot : ICombatFeedbackSink { - public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; } - public void Show(string message) => ViewModel?.AddToast(message); + private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel; + + public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel)) + throw new InvalidOperationException( + "Combat feedback is already bound to a developer view model."); + _viewModel = viewModel; + } + + public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + if (ReferenceEquals(_viewModel, viewModel)) + _viewModel = null; + } + + public void Show(string message) => _viewModel?.AddToast(message); } internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs new file mode 100644 index 00000000..878d4bae --- /dev/null +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -0,0 +1,631 @@ +using AcDream.App.Combat; +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.Core.Player; +using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Panels.Chat; +using AcDream.UI.Abstractions.Panels.Debug; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Panels.Vitals; +using AcDream.UI.ImGui; +using Silk.NET.Input; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace AcDream.App.Composition; + +internal sealed record SettingsDevToolsResult( + AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality, + DevToolsCompositionOwner? DevTools); + +internal sealed record SettingsDevToolsOptionalDependencies( + IDevToolsRuntimeFacts Facts, + IRuntimeKeyBindingTarget? KeyBindingTarget, + DeferredCanonicalWorldEntityCountSource WorldEntities, + DeferredRenderFrameDiagnosticsSource FrameDiagnostics, + DeferredDevToolsPlayerModeCommands PlayerModeCommands); + +internal sealed record SettingsDevToolsDependencies( + IView Window, + RuntimeSettingsController Settings, + IRuntimeSettingsStartupTarget StartupTarget, + HostQuiescenceGate HostQuiescence, + ChatLog Chat, + CombatState Combat, + LocalPlayerState LocalPlayer, + SettingsDevToolsOptionalDependencies? DevTools, + RuntimeDiagnosticCommandSlot DiagnosticCommands, + CombatFeedbackSlot CombatFeedback, + KeyBindings KeyBindings, + FrameProfiler FrameProfiler, + FramebufferResizeController FramebufferResize, + Action Log); + +internal interface IGameWindowSettingsDevToolsPublication +{ + void PublishDevTools(DevToolsCompositionOwner value); +} + +internal interface ISettingsDevToolsCompositionFactory +{ + bool IsSupported(IInputContext input); + + IDevToolsInputContext CreateInputContext( + IInputContext input, + HostQuiescenceGate quiescence); + + IImGuiBootstrapper CreateBootstrap( + GL gl, + IView window, + IInputContext input); + + ImGuiPanelHost CreatePanelHost(); + + IDevToolsFrameBackend CreateBackend( + IImGuiBootstrapper bootstrap, + ImGuiPanelHost panels); +} + +internal sealed class RetailSettingsDevToolsCompositionFactory + : ISettingsDevToolsCompositionFactory +{ + public bool IsSupported(IInputContext input) => + input.Keyboards.Count > 0 + && input.Mice.Count > 0 + && input.Mice[0].ScrollWheels.Count > 0; + + public IDevToolsInputContext CreateInputContext( + IInputContext input, + HostQuiescenceGate quiescence) => + new QuiescentInputContext(input, quiescence); + + public IImGuiBootstrapper CreateBootstrap( + GL gl, + IView window, + IInputContext input) => + new ImGuiBootstrapper(gl, window, input); + + public ImGuiPanelHost CreatePanelHost() => new(); + + public IDevToolsFrameBackend CreateBackend( + IImGuiBootstrapper bootstrap, + ImGuiPanelHost panels) => + new ImGuiDevToolsFrameBackend(bootstrap, panels); +} + +internal enum SettingsDevToolsCompositionPoint +{ + SettingsApplied, + InputContextCreated, + BootstrapCreated, + InputActivated, + PanelHostCreated, + VitalsRegistered, + ChatRegistered, + DebugViewModelCreated, + DiagnosticActionsBound, + CombatFeedbackBound, + DebugRegistered, + SettingsViewModelBound, + SettingsRegistered, + BackendCreated, + PresenterCreated, + FramebufferBound, + InitialLayoutApplied, + DevToolsPublished, +} + +/// +/// Complete optional developer frontend lifetime. Construction rollback and +/// normal shutdown share the same named, retryable release operations. +/// +internal sealed class DevToolsCompositionOwner : IDisposable +{ + private readonly IDevToolsInputContext _input; + private readonly IDevToolsFrameBackend _backend; + private readonly ChatVM _chat; + private readonly DebugVM _debug; + private readonly RuntimeSettingsViewModelBinding? _settings; + private readonly CombatFeedbackSlot _combatFeedback; + private readonly FramebufferDevToolsBinding _framebuffer; + private readonly ResourceShutdownTransaction _frontendShutdown; + private bool _ownsInput; + private bool _ownsBackend; + private bool _ownsChat; + private bool _ownsDebug; + private bool _ownsSettings; + private bool _ownsCombatBinding; + private bool _ownsFramebuffer; + + public DevToolsCompositionOwner( + IDevToolsInputContext input, + IDevToolsFrameBackend backend, + DevToolsFramePresenter presenter, + DevToolsCommandBusSource commandBus, + VitalsVM vitals, + ChatVM chat, + DebugVM debug, + SettingsDevToolsOptionalDependencies lateBindings, + RuntimeSettingsViewModelBinding? settings, + CombatFeedbackSlot combatFeedback, + FramebufferDevToolsBinding framebuffer) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + Presenter = presenter ?? throw new ArgumentNullException(nameof(presenter)); + CommandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + Vitals = vitals ?? throw new ArgumentNullException(nameof(vitals)); + _chat = chat ?? throw new ArgumentNullException(nameof(chat)); + _debug = debug ?? throw new ArgumentNullException(nameof(debug)); + LateBindings = lateBindings + ?? throw new ArgumentNullException(nameof(lateBindings)); + _settings = settings; + _combatFeedback = combatFeedback ?? throw new ArgumentNullException(nameof(combatFeedback)); + _framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer)); + + _frontendShutdown = new ResourceShutdownTransaction( + new ResourceShutdownStage("developer framebuffer binding", + [ + new("framebuffer resize binding", ReleaseFramebuffer), + ]), + new ResourceShutdownStage("developer backend", + [ + new("ImGui backend", ReleaseBackend), + ]), + new ResourceShutdownStage("developer view models", + [ + new("settings view model binding", ReleaseSettings), + new("combat feedback binding", ReleaseCombatBinding), + new("debug combat subscriptions", ReleaseDebug), + new("chat transcript subscription", ReleaseChat), + ])); + } + + public DevToolsFramePresenter Presenter { get; } + public DevToolsCommandBusSource CommandBus { get; } + public VitalsVM Vitals { get; } + public DebugVM Debug => _debug; + public SettingsDevToolsOptionalDependencies LateBindings { get; } + + public bool IsDisposalComplete => + _frontendShutdown.IsComplete && !_ownsInput; + + public void AdoptInput() => _ownsInput = true; + public void AdoptBackend() => _ownsBackend = true; + public void AdoptChat() => _ownsChat = true; + public void AdoptDebug() => _ownsDebug = true; + public void AdoptSettings() => _ownsSettings = _settings is not null; + public void AdoptCombatBinding() => _ownsCombatBinding = true; + public void AdoptFramebuffer() => _ownsFramebuffer = true; + + public void CompleteOwnership() + { + if (!_ownsInput + || !_ownsBackend + || !_ownsChat + || !_ownsDebug + || !_ownsCombatBinding + || !_ownsFramebuffer + || (_settings is not null && !_ownsSettings)) + { + throw new InvalidOperationException( + "Developer composition ownership is incomplete."); + } + } + + public void DeactivateInput() + { + CommandBus.Deactivate(); + LateBindings.PlayerModeCommands.Deactivate(); + LateBindings.FrameDiagnostics.Deactivate(); + LateBindings.WorldEntities.Deactivate(); + if (_ownsInput) + _input.Deactivate(); + } + + public void DetachInput() + { + if (!_ownsInput) + return; + _input.Dispose(); + if (!_input.IsDisposalComplete) + throw new InvalidOperationException( + "Developer input callback cleanup remains incomplete."); + _ownsInput = false; + } + + public void DisposeFrontend() => _frontendShutdown.CompleteOrThrow(); + + public void Dispose() + { + DeactivateInput(); + DisposeFrontend(); + DetachInput(); + } + + private void ReleaseFramebuffer() + { + if (!_ownsFramebuffer) + return; + _framebuffer.Dispose(); + _ownsFramebuffer = false; + } + + private void ReleaseCombatBinding() + { + if (!_ownsCombatBinding) + return; + _combatFeedback.Unbind(_debug); + _ownsCombatBinding = false; + } + + private void ReleaseSettings() + { + if (!_ownsSettings) + return; + _settings!.Dispose(); + _ownsSettings = false; + } + + private void ReleaseDebug() + { + if (!_ownsDebug) + return; + _debug.Dispose(); + _ownsDebug = false; + } + + private void ReleaseChat() + { + if (!_ownsChat) + return; + _chat.Dispose(); + _ownsChat = false; + } + + private void ReleaseBackend() + { + if (!_ownsBackend) + return; + _backend.Dispose(); + _ownsBackend = false; + } +} + +internal sealed class CombatFeedbackBinding : IDisposable +{ + private readonly CombatFeedbackSlot _owner; + private readonly DebugVM _viewModel; + private bool _disposed; + + public CombatFeedbackBinding(CombatFeedbackSlot owner, DebugVM viewModel) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _owner.Bind(_viewModel); + } + + public void Dispose() + { + if (_disposed) + return; + _owner.Unbind(_viewModel); + _disposed = true; + } +} + +/// +/// Production Phase 3. Settings are required; the optional developer frontend +/// may be abandoned only after every locally acquired edge has been removed. +/// +internal sealed class SettingsDevToolsCompositionPhase : + ISettingsDevToolsCompositionPhase< + GameWindowPlatformResult, + HostInputCameraResult, + ContentEffectsAudioResult, + SettingsDevToolsResult> +{ + private readonly SettingsDevToolsDependencies _dependencies; + private readonly IGameWindowSettingsDevToolsPublication _publication; + private readonly ISettingsDevToolsCompositionFactory _factory; + private readonly Action? _faultInjection; + + public SettingsDevToolsCompositionPhase( + SettingsDevToolsDependencies dependencies, + IGameWindowSettingsDevToolsPublication publication, + ISettingsDevToolsCompositionFactory? factory = null, + Action? faultInjection = null) + { + _dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies)); + _publication = publication ?? throw new ArgumentNullException(nameof(publication)); + _factory = factory ?? new RetailSettingsDevToolsCompositionFactory(); + _faultInjection = faultInjection; + } + + public SettingsDevToolsResult Compose( + GameWindowPlatformResult platform, + HostInputCameraResult host, + ContentEffectsAudioResult content) + { + ArgumentNullException.ThrowIfNull(platform); + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(content); + + _dependencies.Settings.ApplyStartup(_dependencies.StartupTarget); + Fault(SettingsDevToolsCompositionPoint.SettingsApplied); + + DevToolsCompositionOwner? devTools = _dependencies.DevTools is { } optional + ? ComposeOptionalDevTools(platform, host, optional) + : null; + return new SettingsDevToolsResult( + _dependencies.Settings.ResolvedQuality, + devTools); + } + + private DevToolsCompositionOwner? ComposeOptionalDevTools( + GameWindowPlatformResult platform, + HostInputCameraResult host, + SettingsDevToolsOptionalDependencies optional) + { + var scope = new CompositionAcquisitionScope(); + bool published = false; + try + { + if (!_factory.IsSupported(platform.Input)) + { + _dependencies.Log( + "devtools: keyboard, mouse, or scroll input unavailable — devtools disabled"); + return null; + } + + var inputLease = scope.Acquire( + "developer input context", + () => _factory.CreateInputContext( + platform.Input, + _dependencies.HostQuiescence), + static value => value.Dispose()); + IDevToolsInputContext input = inputLease.Resource; + Fault(SettingsDevToolsCompositionPoint.InputContextCreated); + + var bootstrapLease = scope.Acquire( + "ImGui bootstrap", + () => _factory.CreateBootstrap( + platform.Graphics, + _dependencies.Window, + input), + static value => value.Dispose()); + IImGuiBootstrapper bootstrap = bootstrapLease.Resource; + Fault(SettingsDevToolsCompositionPoint.BootstrapCreated); + + input.Activate(); + Fault(SettingsDevToolsCompositionPoint.InputActivated); + + ImGuiPanelHost panelHost = _factory.CreatePanelHost(); + Fault(SettingsDevToolsCompositionPoint.PanelHostCreated); + + var vitals = new VitalsVM( + _dependencies.Combat, + _dependencies.LocalPlayer); + var vitalsPanel = new VitalsPanel(vitals); + panelHost.Register(vitalsPanel); + Fault(SettingsDevToolsCompositionPoint.VitalsRegistered); + + var chatLease = scope.Acquire( + "developer chat view model", + () => new ChatVM(_dependencies.Chat) + { + FpsProvider = () => optional.Facts.Fps, + PositionProvider = () => optional.Facts.PlayerPosition, + }, + static value => value.Dispose()); + ChatVM chatVm = chatLease.Resource; + var chatPanel = new ChatPanel(chatVm); + panelHost.Register(chatPanel); + Fault(SettingsDevToolsCompositionPoint.ChatRegistered); + + var debugLease = scope.Acquire( + "developer debug view model", + () => CreateDebugViewModel(optional.Facts), + static value => value.Dispose()); + DebugVM debugVm = debugLease.Resource; + Fault(SettingsDevToolsCompositionPoint.DebugViewModelCreated); + debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay; + debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather; + debugVm.ToggleCollisionWires = + _dependencies.DiagnosticCommands.ToggleCollisionWireframes; + debugVm.ToggleFlyMode = optional.PlayerModeCommands.ToggleFlyOrChase; + Fault(SettingsDevToolsCompositionPoint.DiagnosticActionsBound); + + var combatBindingLease = scope.Acquire( + "combat feedback binding", + () => new CombatFeedbackBinding( + _dependencies.CombatFeedback, + debugVm), + static value => value.Dispose()); + Fault(SettingsDevToolsCompositionPoint.CombatFeedbackBound); + + var debugPanel = new DebugPanel(debugVm); + panelHost.Register(debugPanel); + Fault(SettingsDevToolsCompositionPoint.DebugRegistered); + + RuntimeSettingsViewModelBinding? settingsBinding = null; + SettingsPanel? settingsPanel = null; + CompositionAcquisitionScope.CompositionAcquisitionLease? + settingsLease = null; + if (host.InputDispatcher is not null + && optional.KeyBindingTarget is not null) + { + settingsLease = scope.Acquire( + "settings view model binding", + () => _dependencies.Settings.CreateViewModelBinding( + _dependencies.KeyBindings, + host.InputDispatcher, + optional.KeyBindingTarget.Apply), + static value => value.Dispose()); + settingsBinding = settingsLease.Resource; + Fault(SettingsDevToolsCompositionPoint.SettingsViewModelBound); + settingsPanel = new SettingsPanel(settingsBinding.ViewModel); + panelHost.Register(settingsPanel); + Fault(SettingsDevToolsCompositionPoint.SettingsRegistered); + } + + var backendLease = scope.Acquire( + "developer frame backend", + () => _factory.CreateBackend(bootstrap, panelHost), + static value => value.Dispose()); + IDevToolsFrameBackend backend = backendLease.Resource; + bootstrapLease.Transfer(); + Fault(SettingsDevToolsCompositionPoint.BackendCreated); + + var cameraMenu = new DevToolsCameraMenuOperations( + host.CameraController, + optional.PlayerModeCommands); + var commandBus = new DevToolsCommandBusSource(); + var presenter = new DevToolsFramePresenter( + backend, + cameraMenu, + commandBus, + _dependencies.FrameProfiler, + new DevToolsPanelSet( + vitalsPanel, + chatPanel, + debugPanel, + debugVm, + settingsPanel)); + Fault(SettingsDevToolsCompositionPoint.PresenterCreated); + + var framebufferLease = scope.Acquire( + "developer framebuffer binding", + () => new FramebufferDevToolsBinding( + _dependencies.FramebufferResize, + new DevToolsFramebufferTarget(presenter)), + static value => value.Dispose()); + Fault(SettingsDevToolsCompositionPoint.FramebufferBound); + + presenter.ResetLayout( + _dependencies.Window.Size.X, + _dependencies.Window.Size.Y, + DevToolsPanelLayoutCondition.FirstUseEver); + Fault(SettingsDevToolsCompositionPoint.InitialLayoutApplied); + + var ownerLease = scope.Acquire( + "developer composition owner", + () => new DevToolsCompositionOwner( + input, + backend, + presenter, + commandBus, + vitals, + chatVm, + debugVm, + optional, + settingsBinding, + _dependencies.CombatFeedback, + framebufferLease.Resource), + static value => value.Dispose()); + DevToolsCompositionOwner owner = ownerLease.Resource; + + inputLease.Transfer(); + owner.AdoptInput(); + backendLease.Transfer(); + owner.AdoptBackend(); + chatLease.Transfer(); + owner.AdoptChat(); + debugLease.Transfer(); + owner.AdoptDebug(); + combatBindingLease.Transfer(); + owner.AdoptCombatBinding(); + if (settingsLease is not null) + { + settingsLease.Transfer(); + owner.AdoptSettings(); + } + framebufferLease.Transfer(); + owner.AdoptFramebuffer(); + owner.CompleteOwnership(); + + owner = ownerLease.Publish( + _publication.PublishDevTools); + published = true; + Fault(SettingsDevToolsCompositionPoint.DevToolsPublished); + scope.Complete(); + _dependencies.Log( + "devtools: ImGui panel host ready " + + "(VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)"); + return owner; + } + catch (Exception failure) + { + if (published) + throw; + try + { + scope.RollbackAndThrow(failure); + } + catch (Exception rolledBack) when (!HasIncompleteCleanup(rolledBack)) + { + _dependencies.Log( + $"devtools: ImGui init failed: {rolledBack.Message} — devtools disabled"); + return null; + } + + throw new System.Diagnostics.UnreachableException(); + } + } + + private DebugVM CreateDebugViewModel(IDevToolsRuntimeFacts facts) + { + ArgumentNullException.ThrowIfNull(facts); + return new DebugVM( + () => facts.PlayerPosition, + () => facts.PlayerHeadingDegrees, + () => facts.PlayerCellId, + () => facts.PlayerOnGround, + () => facts.InPlayerMode, + () => facts.InFlyMode, + () => facts.VerticalVelocity, + () => facts.EntityCount, + () => facts.AnimatedCount, + () => facts.VisibleLandblocks, + () => facts.TotalLandblocks, + () => facts.ShadowObjectCount, + () => facts.NearestObjectDistance, + () => facts.NearestObjectLabel, + () => facts.Colliding, + () => facts.CollisionWireframesVisible, + () => facts.StreamingRadius, + () => facts.MouseSensitivity, + () => facts.ChaseDistance, + () => facts.RmbOrbitHeld, + () => facts.HourName, + () => facts.DayFraction, + () => facts.Weather, + () => facts.ActiveLights, + () => facts.RegisteredLights, + () => facts.ParticleCount, + () => facts.Fps, + () => facts.FrameMilliseconds, + _dependencies.Combat); + } + + private static bool HasIncompleteCleanup(Exception failure) + { + if (failure is ImGuiBootstrapperConstructionException) + return true; + if (failure is IRetryableResourceCleanup cleanup + && !cleanup.IsCleanupComplete) + { + return true; + } + if (failure is AggregateException aggregate) + return aggregate.InnerExceptions.Any(HasIncompleteCleanup); + return failure.InnerException is { } inner && HasIncompleteCleanup(inner); + } + + private void Fault(SettingsDevToolsCompositionPoint point) => + _faultInjection?.Invoke(point); +} diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 126f29da..382395ff 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -17,7 +17,9 @@ namespace AcDream.App.Input; /// chase-camera lifetimes. The window host wires this owner but never mutates /// those slots directly. /// -internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations +internal sealed class PlayerModeController : + ILocalPlayerTeleportModeOperations, + IDevToolsPlayerModeTarget { private readonly LocalPlayerModeState _mode; private readonly LocalPlayerControllerSlot _controllerSlot; diff --git a/src/AcDream.App/Input/QuiescentInputContext.cs b/src/AcDream.App/Input/QuiescentInputContext.cs index a4d1c6ea..edfad9ec 100644 --- a/src/AcDream.App/Input/QuiescentInputContext.cs +++ b/src/AcDream.App/Input/QuiescentInputContext.cs @@ -4,13 +4,20 @@ using Silk.NET.Input; namespace AcDream.App.Input; +internal interface IDevToolsInputContext : IInputContext, IDisposable +{ + bool IsDisposalComplete { get; } + void Activate(); + void Deactivate(); +} + /// /// Non-owning input-context view for third-party frontends such as Silk's /// ImGuiController. It interposes named device relays so frontend callbacks can /// be silenced before a long live-session close and physically detached later, /// without disposing the canonical Silk input context. /// -internal sealed class QuiescentInputContext : IInputContext +internal sealed class QuiescentInputContext : IDevToolsInputContext { private readonly IInputContext _inner; private readonly HostQuiescenceGate _quiescence; diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs index c4586550..922c1b2d 100644 --- a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs +++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs @@ -32,7 +32,7 @@ internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery void Render(double deltaSeconds, int viewportWidth, int viewportHeight); } -internal interface IDevToolsFrameBackend +internal interface IDevToolsFrameBackend : IDisposable { void BeginFrame(float deltaSeconds); @@ -93,12 +93,12 @@ internal interface IDevToolsPanelSet /// Concrete ImGui backend for the developer presentation owner. internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable { - private readonly ImGuiBootstrapper _bootstrap; + private readonly IImGuiBootstrapper _bootstrap; private readonly ImGuiPanelHost _panels; private bool _disposed; public ImGuiDevToolsFrameBackend( - ImGuiBootstrapper bootstrap, + IImGuiBootstrapper bootstrap, ImGuiPanelHost panels) { _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); @@ -157,11 +157,14 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDispos internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations { private readonly CameraController _camera; - private PlayerModeController? _playerMode; + private readonly IDevToolsPlayerModeCommands _playerMode; - public DevToolsCameraMenuOperations(CameraController camera) + public DevToolsCameraMenuOperations( + CameraController camera, + IDevToolsPlayerModeCommands playerMode) { _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); } public bool IsFlyMode => _camera.IsFlyMode; @@ -172,25 +175,41 @@ internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperatio set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value; } - public void Bind(PlayerModeController playerMode) - { - _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); - } - - public void ToggleFlyOrChase() => _playerMode?.ToggleFlyOrChase(); + public void ToggleFlyOrChase() => _playerMode.ToggleFlyOrChase(); } /// Resolves the reconnect-safe command bus at draw time. internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource { private LiveSessionController? _session; + private bool _deactivated; public ICommandBus Current => - _session?.Commands ?? NullCommandBus.Instance; + !_deactivated && _session is { } session + ? session.Commands + : NullCommandBus.Instance; public void Bind(LiveSessionController session) { - _session = session ?? throw new ArgumentNullException(nameof(session)); + ArgumentNullException.ThrowIfNull(session); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_session is not null && !ReferenceEquals(_session, session)) + throw new InvalidOperationException( + "Developer command-bus authority is already bound."); + _session = session; + } + + public void Unbind(LiveSessionController session) + { + ArgumentNullException.ThrowIfNull(session); + if (ReferenceEquals(_session, session)) + _session = null; + } + + public void Deactivate() + { + _deactivated = true; + _session = null; } } diff --git a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs new file mode 100644 index 00000000..3e647b2f --- /dev/null +++ b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Physics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; + +namespace AcDream.App.Rendering; + +internal interface IRenderFrameDiagnosticsSnapshotSource +{ + RenderFrameDiagnosticsSnapshot Snapshot { get; } +} + +/// +/// Phase-3 view over the Phase-6 diagnostics owner. Before that owner exists, +/// consumers see the same 60 Hz seed used by the former null-safe closures. +/// +internal sealed class DeferredRenderFrameDiagnosticsSource + : IRenderFrameDiagnosticsSnapshotSource +{ + private IRenderFrameDiagnosticsSnapshotSource? _target; + private bool _deactivated; + + public RenderFrameDiagnosticsSnapshot Snapshot => + !_deactivated && _target is { } target + ? target.Snapshot + : RenderFrameDiagnosticsSnapshot.Initial; + + public void Bind(IRenderFrameDiagnosticsSnapshotSource target) + { + ArgumentNullException.ThrowIfNull(target); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + throw new InvalidOperationException( + "Render-frame diagnostics are already bound."); + _target = target; + } + + public void Unbind(IRenderFrameDiagnosticsSnapshotSource target) + { + ArgumentNullException.ThrowIfNull(target); + if (ReferenceEquals(_target, target)) + _target = null; + } + + public void Deactivate() + { + _deactivated = true; + _target = null; + } +} + +internal interface ICanonicalWorldEntityCountSource +{ + int EntityCount { get; } +} + +/// +/// Prevents early developer UI from retaining the empty bootstrap +/// that is replaced during Phase 6. +/// +internal sealed class DeferredCanonicalWorldEntityCountSource + : ICanonicalWorldEntityCountSource +{ + private GpuWorldState? _target; + private bool _deactivated; + + public int EntityCount => !_deactivated ? _target?.Entities.Count ?? 0 : 0; + + public void Bind(GpuWorldState target) + { + ArgumentNullException.ThrowIfNull(target); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + throw new InvalidOperationException( + "The canonical world entity-count source is already bound."); + _target = target; + } + + public void Unbind(GpuWorldState target) + { + ArgumentNullException.ThrowIfNull(target); + if (ReferenceEquals(_target, target)) + _target = null; + } + + public void Deactivate() + { + _deactivated = true; + _target = null; + } +} + +internal interface IDevToolsPlayerModeCommands +{ + void ToggleFlyOrChase(); +} + +internal interface IDevToolsPlayerModeTarget +{ + void ToggleFlyOrChase(); +} + +/// Phase-3 command edge resolved by the Phase-7 player owner. +internal sealed class DeferredDevToolsPlayerModeCommands + : IDevToolsPlayerModeCommands +{ + private IDevToolsPlayerModeTarget? _target; + private bool _deactivated; + + public void Bind(IDevToolsPlayerModeTarget target) + { + ArgumentNullException.ThrowIfNull(target); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + throw new InvalidOperationException( + "Developer player-mode commands are already bound."); + _target = target; + } + + public void Unbind(IDevToolsPlayerModeTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (ReferenceEquals(_target, target)) + _target = null; + } + + public void Deactivate() + { + _deactivated = true; + _target = null; + } + + public void ToggleFlyOrChase() + { + if (!_deactivated) + _target?.ToggleFlyOrChase(); + } +} + +internal interface IDevToolsRuntimeFacts +{ + Vector3 PlayerPosition { get; } + float PlayerHeadingDegrees { get; } + uint PlayerCellId { get; } + bool PlayerOnGround { get; } + bool InPlayerMode { get; } + bool InFlyMode { get; } + float VerticalVelocity { get; } + int EntityCount { get; } + int AnimatedCount { get; } + int VisibleLandblocks { get; } + int TotalLandblocks { get; } + int ShadowObjectCount { get; } + float NearestObjectDistance { get; } + string NearestObjectLabel { get; } + bool Colliding { get; } + bool CollisionWireframesVisible { get; } + int StreamingRadius { get; } + float MouseSensitivity { get; } + float ChaseDistance { get; } + bool RmbOrbitHeld { get; } + string HourName { get; } + float DayFraction { get; } + string Weather { get; } + int ActiveLights { get; } + int RegisteredLights { get; } + int ParticleCount { get; } + float Fps { get; } + float FrameMilliseconds { get; } +} + +/// +/// Read-only developer presentation model assembled from focused canonical +/// owners. It caches no world, player, frame, or environment values. +/// +internal sealed class DevToolsRuntimeFacts : IDevToolsRuntimeFacts +{ + private readonly ILocalPlayerModeSource _mode; + private readonly ILocalPlayerControllerSource _player; + private readonly CameraController _camera; + private readonly ICanonicalWorldEntityCountSource _world; + private readonly LiveEntityAnimationRuntimeView _animations; + private readonly IDebugVmRenderFactsSource _renderFacts; + private readonly PhysicsEngine _physics; + private readonly IWorldSceneDebugStateSource _sceneDebug; + private readonly IWorldRenderRangeSource _renderRange; + private readonly CameraPointerInputController? _pointer; + private readonly WorldEnvironmentController _environment; + private readonly LightManager _lights; + private readonly ParticleSystem _particles; + private readonly IRenderFrameDiagnosticsSnapshotSource _frame; + + public DevToolsRuntimeFacts( + ILocalPlayerModeSource mode, + ILocalPlayerControllerSource player, + CameraController camera, + ICanonicalWorldEntityCountSource world, + LiveEntityAnimationRuntimeView animations, + IDebugVmRenderFactsSource renderFacts, + PhysicsEngine physics, + IWorldSceneDebugStateSource sceneDebug, + IWorldRenderRangeSource renderRange, + CameraPointerInputController? pointer, + WorldEnvironmentController environment, + LightManager lights, + ParticleSystem particles, + IRenderFrameDiagnosticsSnapshotSource frame) + { + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _renderFacts = renderFacts ?? throw new ArgumentNullException(nameof(renderFacts)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug)); + _renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange)); + _pointer = pointer; + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _lights = lights ?? throw new ArgumentNullException(nameof(lights)); + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _frame = frame ?? throw new ArgumentNullException(nameof(frame)); + } + + public Vector3 PlayerPosition + { + get + { + if (_mode.IsPlayerMode && _player.Controller is { } player) + return player.Position; + Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverse); + return new Vector3(inverse.M41, inverse.M42, inverse.M43); + } + } + + public float PlayerHeadingDegrees + { + get + { + float degrees; + if (_mode.IsPlayerMode && _player.Controller is { } player) + { + degrees = player.Yaw * (180f / MathF.PI); + } + else + { + Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverse); + var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33); + degrees = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI); + } + + degrees %= 360f; + return degrees < 0f ? degrees + 360f : degrees; + } + } + + public uint PlayerCellId => + _mode.IsPlayerMode ? _player.Controller?.CellId ?? 0u : 0u; + public bool PlayerOnGround => + _mode.IsPlayerMode && _player.Controller is { IsAirborne: false }; + public bool InPlayerMode => _mode.IsPlayerMode; + public bool InFlyMode => _camera.IsFlyMode; + public float VerticalVelocity => _player.Controller?.VerticalVelocity ?? 0f; + public int EntityCount => _world.EntityCount; + public int AnimatedCount => _animations.Count; + public int VisibleLandblocks => _renderFacts.DebugVmFacts.VisibleLandblocks; + public int TotalLandblocks => _renderFacts.DebugVmFacts.TotalLandblocks; + public int ShadowObjectCount => _physics.ShadowObjects.TotalRegistered; + public float NearestObjectDistance => _renderFacts.DebugVmFacts.NearestObjectDistance; + public string NearestObjectLabel => _renderFacts.DebugVmFacts.NearestObjectLabel; + public bool Colliding => _renderFacts.DebugVmFacts.Colliding; + public bool CollisionWireframesVisible => _sceneDebug.CollisionWireframesVisible; + public int StreamingRadius => _renderRange.NearRadius; + public float MouseSensitivity => _pointer?.ActiveSensitivity ?? 1f; + public float ChaseDistance => _camera.Chase?.Distance ?? 0f; + public bool RmbOrbitHeld => _pointer?.RmbOrbitHeld ?? false; + public string HourName => _environment.WorldTime.CurrentCalendar.Hour.ToString(); + public float DayFraction => (float)_environment.WorldTime.DayFraction; + public string Weather => _environment.Weather.Kind.ToString(); + public int ActiveLights => _lights.ActiveCount; + public int RegisteredLights => _lights.RegisteredCount; + public int ParticleCount => _particles.ActiveParticleCount; + public float Fps => (float)_frame.Snapshot.Fps; + public float FrameMilliseconds => (float)_frame.Snapshot.FrameMilliseconds; +} diff --git a/src/AcDream.App/Rendering/FramebufferResizeController.cs b/src/AcDream.App/Rendering/FramebufferResizeController.cs index 411aba92..7e89da42 100644 --- a/src/AcDream.App/Rendering/FramebufferResizeController.cs +++ b/src/AcDream.App/Rendering/FramebufferResizeController.cs @@ -50,6 +50,31 @@ internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter DevToolsPanelLayoutCondition.Always); } +/// Expected-owner lease for the optional Phase-3 resize edge. +internal sealed class FramebufferDevToolsBinding : IDisposable +{ + private readonly FramebufferResizeController _owner; + private readonly IFramebufferDevToolsTarget _target; + private bool _disposed; + + public FramebufferDevToolsBinding( + FramebufferResizeController owner, + IFramebufferDevToolsTarget target) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _target = target ?? throw new ArgumentNullException(nameof(target)); + _owner.BindDevTools(_target); + } + + public void Dispose() + { + if (_disposed) + return; + _owner.UnbindDevTools(_target); + _disposed = true; + } +} + /// /// The one framebuffer-size target. Logical gameplay/UI dimensions continue /// to come from Window.Size; this owner only applies physical framebuffer diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b7f0ba56..3638bb18 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -16,7 +16,8 @@ public sealed class GameWindow : IDisposable, IGameWindowPlatformPublication, IGameWindowHostInputCameraPublication, - IGameWindowContentEffectsAudioPublication + IGameWindowContentEffectsAudioPublication, + IGameWindowSettingsDevToolsPublication { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -33,7 +34,6 @@ public sealed class GameWindow : private SilkWindowCallbackBinding? _windowCallbacks; private GL? _gl; private IInputContext? _input; - private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext; private TerrainModernRenderer? _terrain; /// Phase N.5b: terrain_modern.vert/.frag program. Owned by /// at draw time but allocated + disposed here. @@ -345,10 +345,9 @@ public sealed class GameWindow : // Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1. // See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy. - private AcDream.App.Rendering.ImGuiDevToolsFrameBackend? _devToolsBackend; private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter; - private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu; private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus; + private DevToolsCompositionOwner? _devToolsComposition; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm; // Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. @@ -434,7 +433,6 @@ public sealed class GameWindow : private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new(); private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); private readonly FramebufferResizeController _framebufferResize; - private IFramebufferDevToolsTarget? _framebufferDevToolsTarget; private AcDream.App.Input.PlayerModeController? _playerModeController; private readonly AcDream.App.Interaction.PlayerApproachCompletionState _playerApproachCompletions = new(); @@ -784,6 +782,26 @@ public sealed class GameWindow : _audioSink = value.HookSink; } + void IGameWindowSettingsDevToolsPublication.PublishDevTools( + DevToolsCompositionOwner value) + { + ArgumentNullException.ThrowIfNull(value); + if (_devToolsComposition is not null + || _devToolsFramePresenter is not null + || _devToolsCommandBus is not null + || _debugVm is not null) + { + throw new InvalidOperationException( + "The GameWindow composition shell already owns developer tools."); + } + + _devToolsComposition = value; + _devToolsFramePresenter = value.Presenter; + _devToolsCommandBus = value.CommandBus; + _vitalsVm = value.Vitals; + _debugVm = value.Debug; + } + private static void PublishCompositionOwner( ref T? destination, T value, @@ -856,6 +874,64 @@ public sealed class GameWindow : Console.Error.WriteLine), this).Compose(platform, hostInputCamera); + SettingsDevToolsOptionalDependencies? optionalDevTools = null; + if (DevToolsEnabled) + { + var devToolsWorldEntities = + new DeferredCanonicalWorldEntityCountSource(); + var devToolsFrameDiagnostics = + new DeferredRenderFrameDiagnosticsSource(); + var devToolsPlayerModeCommands = + new DeferredDevToolsPlayerModeCommands(); + var devToolsFacts = new DevToolsRuntimeFacts( + _localPlayerMode, + _playerControllerSlot, + hostInputCamera.CameraController, + devToolsWorldEntities, + _animatedEntities, + _debugVmRenderFacts, + _physicsEngine, + _worldSceneDebugState, + _renderRange, + hostInputCamera.CameraPointerInput, + _worldEnvironment, + Lighting, + contentEffectsAudio.ParticleSystem, + devToolsFrameDiagnostics); + IRuntimeKeyBindingTarget? keyBindingTarget = + hostInputCamera.InputDispatcher is { } settingsDispatcher + ? new RuntimeKeyBindingTarget(settingsDispatcher) + : null; + optionalDevTools = new SettingsDevToolsOptionalDependencies( + devToolsFacts, + keyBindingTarget, + devToolsWorldEntities, + devToolsFrameDiagnostics, + devToolsPlayerModeCommands); + } + SettingsDevToolsResult settingsDevTools = + new SettingsDevToolsCompositionPhase( + new SettingsDevToolsDependencies( + _window!, + _runtimeSettings, + new RuntimeSettingsStartupTargets( + new SilkRuntimeDisplayWindowTarget(_window!), + _displayFramePacing, + hostInputCamera.CameraController, + contentEffectsAudio.Audio?.Engine), + _hostQuiescence, + Chat, + Combat, + LocalPlayer, + optionalDevTools, + _runtimeDiagnosticCommands, + _combatFeedback, + _keyBindings, + _frameProfiler, + _framebufferResize, + Console.WriteLine), + this).Compose(platform, hostInputCamera, contentEffectsAudio); + _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); @@ -906,222 +982,6 @@ public sealed class GameWindow : Console.WriteLine("world-hud font: no system monospace font found"); } - // Settings are runtime state, not devtools state. Apply the immutable - // pre-window snapshot once after camera/audio exist and before any - // optional frontend or world/render factory consumes it. - _runtimeSettings.ApplyStartup( - new RuntimeSettingsStartupTargets( - new SilkRuntimeDisplayWindowTarget(_window!), - _displayFramePacing, - _cameraController!, - _audioEngine)); - - // Phase D.2a — ImGui devtools overlay. Zero cost when the env var - // isn't set: no context creation, no per-frame branches hit. - // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. - if (DevToolsEnabled) - { - AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; - AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null; - try - { - _devToolsInputContext = new AcDream.App.Input.QuiescentInputContext( - _input!, - _hostQuiescence); - imguiBootstrap = - new AcDream.UI.ImGui.ImGuiBootstrapper( - _gl!, - _window!, - _devToolsInputContext); - _devToolsInputContext.Activate(); - var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); - - // VitalsVM: GUID=0 at construction; set later at EnterWorld - // (see the _playerServerGuid assignment path). Pre-login the - // HP bar just reads 1.0 (safe default) — harmless. Stam/Mana - // bars surface only after the first PlayerDescription has - // populated LocalPlayer (Issue #5). - _vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); - var vitalsPanel = - new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm); - panelHost.Register(vitalsPanel); - - // ChatPanel: reads the tail of the shared ChatLog. No GUID - // dependency — works pre-login (empty) and post-login (live - // tail of received speech/tells/channels/system msgs). - // FpsProvider + PositionProvider plumb the runtime state - // the client-side /framerate and /loc commands need; the - // panel asks the VM, the VM asks GameWindow via these - // delegates, no panel-vs-renderer-vs-state coupling. - var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat) - { - FpsProvider = () => - (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), - PositionProvider = () => GetDebugPlayerPosition(), - }; - var chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm); - panelHost.Register(chatPanel); - - // Phase I.2: DebugPanel — replaces the deleted custom - // DebugOverlay (six floating panels + hint bar + toast). - // The VM closes over every data source the old snapshot - // record exposed; reads are live (no per-frame snapshot - // build). Action hooks tie the panel's cycle/toggle - // buttons back to the same routines the F2/F7/F10 - // keybinds use. - _debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM( - getPlayerPosition: () => GetDebugPlayerPosition(), - getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(), - getPlayerCellId: () => GetDebugPlayerCellId(), - getPlayerOnGround: () => GetDebugPlayerOnGround(), - getInPlayerMode: () => _playerMode, - getInFlyMode: () => _cameraController?.IsFlyMode ?? false, - getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, - getEntityCount: () => _worldState.Entities.Count, - getAnimatedCount: () => _animatedEntities.Count, - getLandblocksVisible: () => - _debugVmRenderFacts.DebugVmFacts.VisibleLandblocks, - getLandblocksTotal: () => - _debugVmRenderFacts.DebugVmFacts.TotalLandblocks, - getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered, - getNearestObjDist: () => - _debugVmRenderFacts.DebugVmFacts.NearestObjectDistance, - getNearestObjLabel: () => - _debugVmRenderFacts.DebugVmFacts.NearestObjectLabel, - getColliding: () => - _debugVmRenderFacts.DebugVmFacts.Colliding, - getDebugWireframes: () => - _worldSceneDebugState.CollisionWireframesVisible, - getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier - - getMouseSensitivity: () => - _cameraPointerInput?.ActiveSensitivity ?? 1f, - getChaseDistance: () => _chaseCamera?.Distance ?? 0f, - getRmbOrbit: () => - _cameraPointerInput?.RmbOrbitHeld ?? false, - getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), - getDayFraction: () => (float)WorldTime.DayFraction, - getWeather: () => Weather.Kind.ToString(), - getActiveLights: () => Lighting.ActiveCount, - getRegisteredLights: () => Lighting.RegisteredCount, - getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0, - getFps: () => - (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), - getFrameMs: () => - (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), - combat: Combat); - _debugVm.CycleTimeOfDay = - _runtimeDiagnosticCommands.CycleTimeOfDay; - _debugVm.CycleWeather = - _runtimeDiagnosticCommands.CycleWeather; - _debugVm.ToggleCollisionWires = - _runtimeDiagnosticCommands.ToggleCollisionWireframes; - // Phase K.2: free-fly toggle button — same routine the - // legacy F-key alias hits. Cancels the one-shot - // auto-entry if the user opts out of player mode before - // it fires, so the chase camera doesn't snap on top of - // the fly camera mid-inspection. - _debugVm.ToggleFlyMode = () => - _playerModeController?.ToggleFlyOrChase(); - _combatFeedback.ViewModel = _debugVm; - var debugPanel = - new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); - panelHost.Register(debugPanel); - - // Phase K.3 — Settings panel. SettingsVM owns a draft - // copy of the active KeyBindings. Save replaces the - // dispatcher's live table + writes JSON; Cancel reverts - // the draft. Construction is null-safe vs. the - // dispatcher because the dispatcher is built earlier in - // the same OnLoad path (see _inputDispatcher field). - AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null; - if (_inputDispatcher is not null) - { - settingsVm = _runtimeSettings.CreateViewModel( - _keyBindings, - _inputDispatcher, - bindings => - { - _inputDispatcher.SetBindings(bindings); - try - { - bindings.SaveToFile( - AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath()); - Console.WriteLine( - "keybinds: saved to " - + AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath()); - } - catch (Exception ex) - { - Console.WriteLine($"keybinds: save failed: {ex.Message}"); - } - }); - settingsPanel = - new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm); - panelHost.Register(settingsPanel); - } - - Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)"); - - // L.0 Display tab: seed sensible default positions for - // every registered panel. cond=FirstUseEver means imgui.ini - // takes precedence on subsequent launches — the user's - // dragged positions persist. Without this, the first-run - // experience stacks every panel at (0,0) which looks - // broken. - _devToolsBackend = new AcDream.App.Rendering.ImGuiDevToolsFrameBackend( - imguiBootstrap, - panelHost); - _devToolsCameraMenu = - new AcDream.App.Rendering.DevToolsCameraMenuOperations( - _cameraController!); - _devToolsCommandBus = - new AcDream.App.Rendering.DevToolsCommandBusSource(); - _devToolsFramePresenter = - new AcDream.App.Rendering.DevToolsFramePresenter( - _devToolsBackend, - _devToolsCameraMenu, - _devToolsCommandBus, - _frameProfiler, - new AcDream.App.Rendering.DevToolsPanelSet( - vitalsPanel, - chatPanel, - debugPanel, - _debugVm, - settingsPanel)); - _framebufferDevToolsTarget = new DevToolsFramebufferTarget( - _devToolsFramePresenter); - _framebufferResize.BindDevTools(_framebufferDevToolsTarget); - _devToolsFramePresenter.ResetLayout( - _window!.Size.X, - _window.Size.Y, - AcDream.App.Rendering.DevToolsPanelLayoutCondition.FirstUseEver); - } - catch (Exception ex) - { - Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled"); - // The focused backend borrows the bootstrap during normal - // operation. Construction failure still owns this local and - // must release it before abandoning the optional frontend. - imguiBootstrap?.Dispose(); - if (_framebufferDevToolsTarget is { } framebufferTarget) - { - _framebufferResize.UnbindDevTools(framebufferTarget); - _framebufferDevToolsTarget = null; - } - _devToolsInputContext?.Dispose(); - _devToolsInputContext = null; - _devToolsBackend = null; - _devToolsFramePresenter = null; - _devToolsCameraMenu = null; - _devToolsCommandBus = null; - _vitalsVm = null; - _combatFeedback.ViewModel = null; - _debugVm = null; - _runtimeSettings.UnbindViewModel(settingsVm); - } - } - uint centerLandblockId = 0xA9B4FFFFu; Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); @@ -1755,6 +1615,7 @@ public sealed class GameWindow : wbSpawnAdapter, onLandblockUnloaded: _classificationCache.InvalidateLandblock, entityScriptActivator: entityScriptActivator); + _devToolsComposition?.LateBindings.WorldEntities.Bind(_worldState); _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, new AcDream.App.World.CompositeLiveEntityResourceLifecycle( @@ -2117,6 +1978,8 @@ public sealed class GameWindow : _renderDiagnosticLog, _options.UiProbeDump, resourceDiagnostics); + _devToolsComposition?.LateBindings.FrameDiagnostics.Bind( + _renderFrameDiagnostics); // Apply radii from the same immutable quality snapshot used for the // window's MSAA, terrain anisotropy, and dispatcher A2C setup. @@ -2498,7 +2361,8 @@ public sealed class GameWindow : _movementTruthDiagnostics, _localPlayerSkills, _viewportAspect); - _devToolsCameraMenu?.Bind(_playerModeController); + _devToolsComposition?.LateBindings.PlayerModeCommands.Bind( + _playerModeController); _devToolsCommandBus?.Bind(_liveSessionController); _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( new AcDream.App.Input.LivePlayerModeAutoEntryContext( @@ -3216,59 +3080,6 @@ public sealed class GameWindow : // EXPECTED-DIFF: local sidestep pacing now matches how remotes have // always played (w6-cutover-map.md R3). - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── - // - // The ImGui DebugPanel reads through DebugVM closures that ask - // GameWindow for live state on every frame. The helper methods below - // are the *named* targets of those closures (and of the F-key - // shortcuts that share the same actions). Keeping them as methods - // (vs ad-hoc lambdas where the VM is constructed) means both the - // panel button and the keybind run the *same* code, so behavior - // can't drift between the two surfaces. - - /// Player-mode-aware position source for the DebugPanel. - private System.Numerics.Vector3 GetDebugPlayerPosition() - { - if (_playerMode && _playerController is not null) - return _playerController.Position; - if (_cameraController?.Active is { } cam) - { - // Camera world position from inverse of view matrix — same - // computation used by the scene-lighting UBO each frame. - System.Numerics.Matrix4x4.Invert(cam.View, out var inv); - return new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43); - } - return System.Numerics.Vector3.Zero; - } - - /// Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise. - private float GetDebugPlayerHeadingDeg() - { - float deg; - if (_playerMode && _playerController is not null) - { - deg = _playerController.Yaw * (180f / MathF.PI); - } - else if (_cameraController?.Active is { } cam) - { - // Camera-relative heading from view matrix forward vector. Use - // the same -invView.Mxx convention the snapshot block used. - System.Numerics.Matrix4x4.Invert(cam.View, out var inv); - var fwd = new System.Numerics.Vector3(-inv.M31, -inv.M32, -inv.M33); - deg = MathF.Atan2(fwd.Y, fwd.X) * (180f / MathF.PI); - } - else - { - return 0f; - } - deg %= 360f; - if (deg < 0f) deg += 360f; - return deg; - } - - private uint GetDebugPlayerCellId() => - _playerMode && _playerController is not null ? _playerController.CellId : 0u; - private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; @@ -3419,7 +3230,7 @@ public sealed class GameWindow : new("mouse source", () => _mouseSource?.Deactivate()), new("keyboard source", () => _kbSource?.Deactivate()), new("retained UI input", _retailUiLease.QuiesceInput), - new("developer tools input", () => _devToolsInputContext?.Deactivate()), + new("developer tools input", () => _devToolsComposition?.DeactivateInput()), ]), // Live-session reset retires the complete streaming window. Every // reset callback owner, especially LandblockStreamer, must remain live @@ -3473,7 +3284,7 @@ public sealed class GameWindow : _gameplayInputActions = null; }), new("retained UI input", _retailUiLease.DeactivateInput), - new("developer tools input", () => _devToolsInputContext?.Dispose()), + new("developer tools input", () => _devToolsComposition?.DetachInput()), new("camera pointer", () => { AcDream.App.Input.CameraPointerInputController? pointer = @@ -3650,17 +3461,17 @@ public sealed class GameWindow : [ new("developer tools", () => { - if (_framebufferDevToolsTarget is { } framebufferTarget) - { - _framebufferResize.UnbindDevTools(framebufferTarget); - _framebufferDevToolsTarget = null; - } + DevToolsCompositionOwner? owner = _devToolsComposition; + if (owner is null) + return; + owner.DisposeFrontend(); + if (!owner.IsDisposalComplete) + throw new InvalidOperationException( + "Developer-tools cleanup remains incomplete."); + _devToolsComposition = null; _devToolsFramePresenter = null; - _devToolsCameraMenu = null; _devToolsCommandBus = null; - _devToolsBackend?.Dispose(); - _devToolsBackend = null; - _devToolsInputContext = null; + _debugVm = null; }), new("portal tunnel", () => { diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs index bb78cc1c..531a83bd 100644 --- a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs @@ -137,7 +137,9 @@ internal readonly record struct RenderFrameResourceDiagnosticsSnapshot( /// Publishes post-presentation frame statistics. The caller invokes this only after /// screenshot capture, preserving the accepted screenshot-before-title/resource order. /// -internal sealed class RenderFrameDiagnosticsController : IRenderFrameDiagnosticsPhase +internal sealed class RenderFrameDiagnosticsController : + IRenderFrameDiagnosticsPhase, + IRenderFrameDiagnosticsSnapshotSource { internal const double PublicationIntervalSeconds = 0.5; diff --git a/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs b/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs new file mode 100644 index 00000000..1fcef725 --- /dev/null +++ b/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs @@ -0,0 +1,45 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Settings; + +internal interface IRuntimeKeyBindingTarget +{ + void Apply(KeyBindings bindings); +} + +/// +/// Applies a settings-panel keymap to the live dispatcher and then persists +/// that exact map. Persistence failure is reported without rolling back the +/// already accepted live binding, matching the former startup-root callback. +/// +internal sealed class RuntimeKeyBindingTarget : IRuntimeKeyBindingTarget +{ + private readonly InputDispatcher _dispatcher; + private readonly string _path; + private readonly Action _log; + + public RuntimeKeyBindingTarget( + InputDispatcher dispatcher, + string? path = null, + Action? log = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _path = path ?? KeyBindings.DefaultPath(); + _log = log ?? Console.WriteLine; + } + + public void Apply(KeyBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + _dispatcher.SetBindings(bindings); + try + { + bindings.SaveToFile(_path); + _log($"keybinds: saved to {_path}"); + } + catch (Exception failure) + { + _log($"keybinds: save failed: {failure.Message}"); + } + } +} diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index e4e573e8..814dfc34 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -79,6 +79,34 @@ internal sealed record RuntimeSettingsSnapshot( CharacterSettings Character, QualitySettings Quality); +/// +/// Expected-owner lease for the optional developer settings view model. +/// Failed optional composition can withdraw only the instance it installed. +/// +internal sealed class RuntimeSettingsViewModelBinding : IDisposable +{ + private readonly RuntimeSettingsController _owner; + private bool _disposed; + + public RuntimeSettingsViewModelBinding( + RuntimeSettingsController owner, + SettingsVM viewModel) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + } + + public SettingsVM ViewModel { get; } + + public void Dispose() + { + if (_disposed) + return; + _owner.UnbindViewModel(ViewModel); + _disposed = true; + } +} + internal interface IRuntimeSettingsStartupTarget { void ApplyDisplay(DisplaySettings display); @@ -248,6 +276,14 @@ internal sealed class RuntimeSettingsController : return _viewModel; } + public RuntimeSettingsViewModelBinding CreateViewModelBinding( + KeyBindings persistedBindings, + InputDispatcher dispatcher, + Action saveBindings) => + new( + this, + CreateViewModel(persistedBindings, dispatcher, saveBindings)); + public void UnbindViewModel(SettingsVM? expected = null) { if (expected is null || ReferenceEquals(_viewModel, expected)) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 04fd3871..5894725e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -21,13 +21,14 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// unchanged transcript does not require a queue snapshot each frame. /// /// -public sealed class ChatVM +public sealed class ChatVM : IDisposable { /// Default number of tail entries rendered. public const int DefaultDisplayLimit = 20; private readonly ChatLog _log; private readonly int _displayLimit; + private bool _disposed; /// /// Sender name of the most recent INCOMING Tell. Drives the @@ -104,6 +105,14 @@ public sealed class ChatVM LastOutgoingTellTarget = entry.Sender; } + public void Dispose() + { + if (_disposed) + return; + _log.EntryAppended -= OnEntryAppended; + _disposed = true; + } + /// /// Append a client-side system line to the chat log. Used by /// client-handled commands (/help, /clear, future) to surface diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs index bf4123f8..09330c68 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -67,7 +67,7 @@ public readonly record struct ToastMessage( /// debug panels appear. /// /// -public sealed class DebugVM +public sealed class DebugVM : IDisposable { /// Maximum number of combat-event lines kept in the ring. public const int MaxCombatEvents = 25; @@ -103,6 +103,8 @@ public sealed class DebugVM private readonly Func _getParticleCount; private readonly Func _getFps; private readonly Func _getFrameMs; + private readonly CombatState _combat; + private bool _disposed; private readonly Queue _combatEvents = new(); private readonly Queue _toasts = new(); @@ -144,7 +146,7 @@ public sealed class DebugVM Func getFrameMs, CombatState combat) { - if (combat is null) throw new ArgumentNullException(nameof(combat)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition)); _getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg)); _getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId)); @@ -177,21 +179,12 @@ public sealed class DebugVM // Self-subscribe to combat events. Each one becomes a typed entry // in the ring; the panel renders them in TextColored. Replaces // the old DebugOverlay.BindCombat side-channel. - combat.DamageTaken += d => Push(CombatEventKind.Error, - $"<< {d.AttackerName} hit you for {d.Damage}{(d.Critical ? " CRIT!" : "")}"); - combat.DamageDealtAccepted += d => Push(CombatEventKind.Info, - $">> you hit {d.DefenderName} for {d.Damage}"); - combat.EvadedIncoming += attacker => Push(CombatEventKind.Warn, - $"<< {attacker}'s attack missed you"); - combat.MissedOutgoing += defender => Push(CombatEventKind.Info, - $">> your attack missed {defender}"); - combat.AttackDone += (_, weenieError) => - { - if (weenieError != 0) - Push(CombatEventKind.Error, $"!! attack failed (error 0x{weenieError:X})"); - }; - combat.KillLanded += (victim, _) => - Push(CombatEventKind.Info, $"** you killed {victim}"); + _combat.DamageTaken += OnDamageTaken; + _combat.DamageDealtAccepted += OnDamageDealt; + _combat.EvadedIncoming += OnEvadedIncoming; + _combat.MissedOutgoing += OnMissedOutgoing; + _combat.AttackDone += OnAttackDone; + _combat.KillLanded += OnKillLanded; } // ── Read-through value surfaces ─────────────────────────────────── @@ -510,4 +503,46 @@ public sealed class DebugVM while (_combatEvents.Count > MaxCombatEvents) _combatEvents.Dequeue(); } + + public void Dispose() + { + if (_disposed) + return; + + _combat.DamageTaken -= OnDamageTaken; + _combat.DamageDealtAccepted -= OnDamageDealt; + _combat.EvadedIncoming -= OnEvadedIncoming; + _combat.MissedOutgoing -= OnMissedOutgoing; + _combat.AttackDone -= OnAttackDone; + _combat.KillLanded -= OnKillLanded; + _disposed = true; + } + + private void OnDamageTaken(CombatState.DamageIncoming damage) => + Push( + CombatEventKind.Error, + $"<< {damage.AttackerName} hit you for {damage.Damage}" + + (damage.Critical ? " CRIT!" : string.Empty)); + + private void OnDamageDealt(CombatState.DamageDealt damage) => + Push( + CombatEventKind.Info, + $">> you hit {damage.DefenderName} for {damage.Damage}"); + + private void OnEvadedIncoming(string attacker) => + Push(CombatEventKind.Warn, $"<< {attacker}'s attack missed you"); + + private void OnMissedOutgoing(string defender) => + Push(CombatEventKind.Info, $">> your attack missed {defender}"); + + private void OnAttackDone(uint _, uint weenieError) + { + if (weenieError != 0) + Push( + CombatEventKind.Error, + $"!! attack failed (error 0x{weenieError:X})"); + } + + private void OnKillLanded(string victim, uint _) => + Push(CombatEventKind.Info, $"** you killed {victim}"); } diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs index 04daaab8..23d46536 100644 --- a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -28,7 +28,31 @@ namespace AcDream.UI.ImGui; /// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25. /// /// -public sealed class ImGuiBootstrapper : IDisposable +public interface IImGuiBootstrapper : IDisposable +{ + void BeginFrame(float deltaSeconds); + void Render(); + void AbortFrame(); +} + +/// +/// The upstream Silk controller does not expose a partially constructed +/// instance. A constructor failure therefore requires teardown of the owning +/// GL/window host instead of being treated as a recoverable optional-frontend +/// failure. +/// +public sealed class ImGuiBootstrapperConstructionException : Exception +{ + internal ImGuiBootstrapperConstructionException(Exception innerException) + : base( + "Silk ImGui construction did not publish a cleanup owner; " + + "the graphics host must be torn down.", + innerException) + { + } +} + +public sealed class ImGuiBootstrapper : IImGuiBootstrapper { private readonly ImGuiController _controller; @@ -42,7 +66,21 @@ public sealed class ImGuiBootstrapper : IDisposable // - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL) // - Keyboard + mouse event subscription (bound to Silk.NET IInputContext) // - Default style = dark - _controller = new ImGuiController(gl, window, input); + nint priorContext = ImGuiNET.ImGui.GetCurrentContext(); + try + { + _controller = new ImGuiController(gl, window, input); + } + catch (Exception failure) + { + nint partialContext = ImGuiNET.ImGui.GetCurrentContext(); + if (partialContext != 0 && partialContext != priorContext) + { + ImGuiNET.ImGui.DestroyContext(partialContext); + ImGuiNET.ImGui.SetCurrentContext(priorContext); + } + throw new ImGuiBootstrapperConstructionException(failure); + } } /// diff --git a/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs b/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs new file mode 100644 index 00000000..4fe8a669 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs @@ -0,0 +1,59 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Panels.Debug; + +namespace AcDream.App.Tests.Combat; + +public sealed class CombatFeedbackSlotTests +{ + [Fact] + public void ExpectedOwnerUnbindCannotClearReplacement() + { + var slot = new CombatFeedbackSlot(); + using DebugVM first = CreateViewModel(); + using DebugVM second = CreateViewModel(); + + slot.Bind(first); + slot.Unbind(second); + slot.Show("first"); + Assert.Single(first.RecentToasts); + Assert.Empty(second.RecentToasts); + + slot.Unbind(first); + slot.Bind(second); + slot.Show("second"); + Assert.Single(second.RecentToasts); + } + + private static DebugVM CreateViewModel() => new( + static () => Vector3.Zero, + static () => 0, + static () => 0, + static () => false, + static () => false, + static () => false, + static () => 0, + static () => 0, + static () => 0, + static () => 0, + static () => 0, + static () => 0, + static () => float.PositiveInfinity, + static () => "-", + static () => false, + static () => false, + static () => 0, + static () => 1, + static () => 0, + static () => false, + static () => "0", + static () => 0, + static () => "Clear", + static () => 0, + static () => 0, + static () => 0, + static () => 60, + static () => 16.7f, + new CombatState()); +} diff --git a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs new file mode 100644 index 00000000..e0ddc3dc --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs @@ -0,0 +1,520 @@ +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.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.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(), + 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() + .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 OptionalFailurePoints() + { + var data = new TheoryData(); + foreach (SettingsDevToolsCompositionPoint point in + Enum.GetValues()) + { + 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(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(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(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, + _dispatcher, + camera, + null); + Platform = new GameWindowPlatformResult(null!, null!); + Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject( + typeof(ContentEffectsAudioResult)); + Dependencies = new SettingsDevToolsDependencies( + DispatchProxy.Create(), + Settings, + Startup, + new HostQuiescenceGate(), + new ChatLog(), + new CombatState(), + new LocalPlayerState(new Spellbook()), + 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 Points { get; } = []; + public List 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 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() + { + 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 Gamepads { get; } = []; + public IReadOnlyList Joysticks { get; } = []; + public IReadOnlyList Keyboards { get; } = []; + public IReadOnlyList Mice { get; } = []; + public IReadOnlyList OtherDevices { get; } = []; +#pragma warning disable CS0067 + public event Action? 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? KeyDown; + public event Action? 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? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? 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(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."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs index 3b0c6139..391ce750 100644 --- a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs @@ -283,6 +283,7 @@ public sealed class DevToolsFramePresenterTests private sealed class RecordingBackend(List calls) : IDevToolsFrameBackend { + public void Dispose() { } public HashSet OpenMenus { get; } = []; public HashSet ClickedItems { get; } = []; public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs new file mode 100644 index 00000000..841e5a16 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs @@ -0,0 +1,98 @@ +using AcDream.App.Rendering; +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DevToolsRuntimeSourcesTests +{ + [Fact] + public void FrameDiagnosticsLateBindingPreservesSeedAndExpectedOwnerRelease() + { + var source = new DeferredRenderFrameDiagnosticsSource(); + var first = new FrameSource(144, 6.9); + var other = new FrameSource(30, 33.3); + + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot); + source.Bind(first); + Assert.Equal(144, source.Snapshot.Fps); + source.Unbind(other); + Assert.Equal(144, source.Snapshot.Fps); + source.Unbind(first); + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot); + + source.Bind(other); + source.Deactivate(); + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot); + Assert.Throws(() => source.Bind(first)); + } + + [Fact] + public void FrameDiagnosticsRejectsACompetingCanonicalOwner() + { + var source = new DeferredRenderFrameDiagnosticsSource(); + source.Bind(new FrameSource(60, 16.7)); + + Assert.Throws(() => + source.Bind(new FrameSource(120, 8.3))); + } + + [Fact] + public void PlayerModeCommandSlotUsesLiveTargetAndRejectsStaleRelease() + { + var source = new DeferredDevToolsPlayerModeCommands(); + var first = new PlayerModeTarget(); + var other = new PlayerModeTarget(); + + source.ToggleFlyOrChase(); + source.Bind(first); + source.ToggleFlyOrChase(); + source.Unbind(other); + source.ToggleFlyOrChase(); + source.Unbind(first); + source.ToggleFlyOrChase(); + + Assert.Equal(2, first.ToggleCalls); + Assert.Equal(0, other.ToggleCalls); + + source.Bind(other); + source.Deactivate(); + source.ToggleFlyOrChase(); + Assert.Equal(0, other.ToggleCalls); + Assert.Throws(() => source.Bind(first)); + } + + [Fact] + public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld() + { + var source = new DeferredCanonicalWorldEntityCountSource(); + var first = new GpuWorldState(); + var other = new GpuWorldState(); + + source.Bind(first); + source.Unbind(other); + Assert.Throws(() => source.Bind(other)); + source.Unbind(first); + source.Bind(other); + source.Deactivate(); + + Assert.Equal(0, source.EntityCount); + Assert.Throws(() => source.Bind(first)); + } + + private sealed class FrameSource(double fps, double milliseconds) + : IRenderFrameDiagnosticsSnapshotSource + { + public RenderFrameDiagnosticsSnapshot Snapshot { get; } = + RenderFrameDiagnosticsSnapshot.Initial with + { + Fps = fps, + FrameMilliseconds = milliseconds, + }; + } + + private sealed class PlayerModeTarget : IDevToolsPlayerModeTarget + { + public int ToggleCalls { get; private set; } + public void ToggleFlyOrChase() => ToggleCalls++; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs index e26d845e..c47cb158 100644 --- a/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs @@ -57,6 +57,22 @@ public sealed class FramebufferResizeControllerTests Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls); } + [Fact] + public void DevToolsBindingReleasesExpectedTargetWithoutReplayingResize() + { + var calls = new List(); + var owner = new FramebufferResizeController(new ViewportAspectState()); + var target = new DevTools(calls); + using var binding = new FramebufferDevToolsBinding(owner, target); + + owner.Resize(900, 700); + binding.Dispose(); + binding.Dispose(); + owner.Resize(1000, 800); + + Assert.Equal(["devtools:900x700"], calls); + } + private sealed class Viewport(List calls) : IFramebufferViewportTarget { public void ResizeViewport(int width, int height) => diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index cf866a99..b370b12f 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -94,7 +94,13 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); - Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source); + string settingsComposition = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SettingsDevToolsComposition.cs")); + Assert.Contains("new DevToolsFramePresenter(", settingsComposition); Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source); Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source); Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); @@ -119,7 +125,7 @@ public sealed class GameWindowRenderLeafCompositionTests "new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"render frontends\"", "new(\"developer tools\"", - "_devToolsBackend?.Dispose()", + "owner.DisposeFrontend()", "new(\"portal tunnel\"", "new(\"paperdoll viewport\"", "new ResourceShutdownStage(\"OpenGL context\""); @@ -135,7 +141,7 @@ public sealed class GameWindowRenderLeafCompositionTests AssertAppearsInOrder( source, "_frameGraphs.Withdraw();", - "_devToolsBackend?.Dispose()", + "owner.DisposeFrontend()", "new ResourceShutdownStage(\"input\"", "_input?.Dispose();", "new ResourceShutdownStage(\"OpenGL context\""); diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 2c2dd936..1286bc70 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -56,8 +56,8 @@ public sealed class GameWindowSlice8BoundaryTests "this).Compose(platform);", "new ContentEffectsAudioCompositionPhase(", "this).Compose(platform, hostInputCamera);", - "_runtimeSettings.ApplyStartup(", - "new RuntimeSettingsStartupTargets(", + "new SettingsDevToolsCompositionPhase(", + "this).Compose(platform, hostInputCamera, contentEffectsAudio);", "_uiHost = _retailUiLease.AcquireHost(", "_uiHost.WireMouse(m)", "_uiHost.WireKeyboard(kb)", @@ -138,10 +138,19 @@ public sealed class GameWindowSlice8BoundaryTests Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal); Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal); + string settingsPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SettingsDevToolsComposition.cs")); + AssertAppearsInOrder( + settingsPhase, + "debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;", + "debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;", + "debugVm.ToggleCollisionWires ="); AssertAppearsInOrder( load, - "_debugVm.CycleTimeOfDay =", - "_runtimeDiagnosticCommands.CycleTimeOfDay;", "new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(", "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);"); } @@ -267,11 +276,21 @@ public sealed class GameWindowSlice8BoundaryTests "Window.Create(options)"); AssertAppearsInOrder( load, - "_runtimeSettings.ApplyStartup(", - "if (DevToolsEnabled)", + "new SettingsDevToolsCompositionPhase(", + "this).Compose(platform, hostInputCamera, contentEffectsAudio);", "TerrainAtlas.Build(", "_runtimeSettings.BindRuntimeTargets(", "_liveSessionHost.Start(_options)"); + string settingsPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SettingsDevToolsComposition.cs")); + AssertAppearsInOrder( + settingsPhase, + "_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);", + "_dependencies.DevTools is { } optional"); AssertAppearsInOrder( shutdown, "_runtimeSettings.UnbindViewModel()", diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index 3ff2177d..118c3282 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -81,6 +81,31 @@ public sealed class RuntimeSettingsControllerTests controller.BindRuntimeTargets(new FakeRuntimeTargets(events))); } + [Fact] + public void ViewModelBindingReleasesOnlyItsExpectedInstance() + { + var controller = CreateController(); + using InputDispatcher dispatcher = CreateDispatcher(); + RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding( + new KeyBindings(), + dispatcher, + static _ => { }); + + first.Dispose(); + RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding( + new KeyBindings(), + dispatcher, + static _ => { }); + first.Dispose(); + + Assert.Throws(() => + controller.CreateViewModelBinding( + new KeyBindings(), + dispatcher, + static _ => { })); + second.Dispose(); + } + [Fact] public void StartupRetryResumesAfterLastSuccessfulStage() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs index f2a81348..91a3e4cf 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs @@ -12,6 +12,20 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// public sealed class ChatVMLastTellSenderTests { + [Fact] + public void DisposeDetachesTranscriptSubscriptionExactlyOnce() + { + var log = new ChatLog(); + var vm = new ChatVM(log); + log.OnTellReceived("Before", "ping", 0x5000_0001u); + + vm.Dispose(); + vm.Dispose(); + log.OnTellReceived("After", "pong", 0x5000_0002u); + + Assert.Equal("Before", vm.LastIncomingTellSender); + } + [Fact] public void LastIncomingTellSender_StartsNull() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs index f89cb7fe..3028cfd0 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs @@ -14,6 +14,22 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Debug; /// public sealed class DebugVMTests { + [Fact] + public void DisposeDetachesEveryCombatSubscriptionExactlyOnce() + { + var combat = new CombatState(); + DebugVM vm = NewVm(combat); + combat.OnKillerNotification("first", 1); + Assert.Single(vm.CombatEvents); + + vm.Dispose(); + vm.Dispose(); + combat.OnKillerNotification("second", 2); + combat.OnAttackDone(3, 4); + + Assert.Single(vm.CombatEvents); + } + /// /// Build a minimal with safe defaults for every /// constructor source. Tests that don't care about a particular source