refactor(app): compose settings and developer tools

This commit is contained in:
Erik 2026-07-22 16:11:34 +02:00
parent 60a1698ce7
commit cd7b519f78
24 changed files with 2073 additions and 333 deletions

View file

@ -33,8 +33,25 @@ internal interface ICombatFeedbackSink
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
{ {
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; } private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel;
public void Show(string message) => ViewModel?.AddToast(message);
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 internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations

View file

@ -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<string> 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,
}
/// <summary>
/// Complete optional developer frontend lifetime. Construction rollback and
/// normal shutdown share the same named, retryable release operations.
/// </summary>
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;
}
}
/// <summary>
/// Production Phase 3. Settings are required; the optional developer frontend
/// may be abandoned only after every locally acquired edge has been removed.
/// </summary>
internal sealed class SettingsDevToolsCompositionPhase :
ISettingsDevToolsCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult>
{
private readonly SettingsDevToolsDependencies _dependencies;
private readonly IGameWindowSettingsDevToolsPublication _publication;
private readonly ISettingsDevToolsCompositionFactory _factory;
private readonly Action<SettingsDevToolsCompositionPoint>? _faultInjection;
public SettingsDevToolsCompositionPhase(
SettingsDevToolsDependencies dependencies,
IGameWindowSettingsDevToolsPublication publication,
ISettingsDevToolsCompositionFactory? factory = null,
Action<SettingsDevToolsCompositionPoint>? 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<GL, IInputContext> 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<GL, IInputContext> 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<RuntimeSettingsViewModelBinding>?
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);
}

View file

@ -17,7 +17,9 @@ namespace AcDream.App.Input;
/// chase-camera lifetimes. The window host wires this owner but never mutates /// chase-camera lifetimes. The window host wires this owner but never mutates
/// those slots directly. /// those slots directly.
/// </summary> /// </summary>
internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations internal sealed class PlayerModeController :
ILocalPlayerTeleportModeOperations,
IDevToolsPlayerModeTarget
{ {
private readonly LocalPlayerModeState _mode; private readonly LocalPlayerModeState _mode;
private readonly LocalPlayerControllerSlot _controllerSlot; private readonly LocalPlayerControllerSlot _controllerSlot;

View file

@ -4,13 +4,20 @@ using Silk.NET.Input;
namespace AcDream.App.Input; namespace AcDream.App.Input;
internal interface IDevToolsInputContext : IInputContext, IDisposable
{
bool IsDisposalComplete { get; }
void Activate();
void Deactivate();
}
/// <summary> /// <summary>
/// Non-owning input-context view for third-party frontends such as Silk's /// Non-owning input-context view for third-party frontends such as Silk's
/// ImGuiController. It interposes named device relays so frontend callbacks can /// ImGuiController. It interposes named device relays so frontend callbacks can
/// be silenced before a long live-session close and physically detached later, /// be silenced before a long live-session close and physically detached later,
/// without disposing the canonical Silk input context. /// without disposing the canonical Silk input context.
/// </summary> /// </summary>
internal sealed class QuiescentInputContext : IInputContext internal sealed class QuiescentInputContext : IDevToolsInputContext
{ {
private readonly IInputContext _inner; private readonly IInputContext _inner;
private readonly HostQuiescenceGate _quiescence; private readonly HostQuiescenceGate _quiescence;

View file

@ -32,7 +32,7 @@ internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
void Render(double deltaSeconds, int viewportWidth, int viewportHeight); void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
} }
internal interface IDevToolsFrameBackend internal interface IDevToolsFrameBackend : IDisposable
{ {
void BeginFrame(float deltaSeconds); void BeginFrame(float deltaSeconds);
@ -93,12 +93,12 @@ internal interface IDevToolsPanelSet
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary> /// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
{ {
private readonly ImGuiBootstrapper _bootstrap; private readonly IImGuiBootstrapper _bootstrap;
private readonly ImGuiPanelHost _panels; private readonly ImGuiPanelHost _panels;
private bool _disposed; private bool _disposed;
public ImGuiDevToolsFrameBackend( public ImGuiDevToolsFrameBackend(
ImGuiBootstrapper bootstrap, IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels) ImGuiPanelHost panels)
{ {
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
@ -157,11 +157,14 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDispos
internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations
{ {
private readonly CameraController _camera; 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)); _camera = camera ?? throw new ArgumentNullException(nameof(camera));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
} }
public bool IsFlyMode => _camera.IsFlyMode; public bool IsFlyMode => _camera.IsFlyMode;
@ -172,25 +175,41 @@ internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperatio
set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value; set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value;
} }
public void Bind(PlayerModeController playerMode) public void ToggleFlyOrChase() => _playerMode.ToggleFlyOrChase();
{
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
}
public void ToggleFlyOrChase() => _playerMode?.ToggleFlyOrChase();
} }
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary> /// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
{ {
private LiveSessionController? _session; private LiveSessionController? _session;
private bool _deactivated;
public ICommandBus Current => public ICommandBus Current =>
_session?.Commands ?? NullCommandBus.Instance; !_deactivated && _session is { } session
? session.Commands
: NullCommandBus.Instance;
public void Bind(LiveSessionController session) 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;
} }
} }

View file

@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>
/// Prevents early developer UI from retaining the empty bootstrap
/// <see cref="GpuWorldState"/> that is replaced during Phase 6.
/// </summary>
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();
}
/// <summary>Phase-3 command edge resolved by the Phase-7 player owner.</summary>
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; }
}
/// <summary>
/// Read-only developer presentation model assembled from focused canonical
/// owners. It caches no world, player, frame, or environment values.
/// </summary>
internal sealed class DevToolsRuntimeFacts : IDevToolsRuntimeFacts
{
private readonly ILocalPlayerModeSource _mode;
private readonly ILocalPlayerControllerSource _player;
private readonly CameraController _camera;
private readonly ICanonicalWorldEntityCountSource _world;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _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<LiveEntityAnimationState> 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;
}

View file

@ -50,6 +50,31 @@ internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter
DevToolsPanelLayoutCondition.Always); DevToolsPanelLayoutCondition.Always);
} }
/// <summary>Expected-owner lease for the optional Phase-3 resize edge.</summary>
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;
}
}
/// <summary> /// <summary>
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue /// The one framebuffer-size target. Logical gameplay/UI dimensions continue
/// to come from Window.Size; this owner only applies physical framebuffer /// to come from Window.Size; this owner only applies physical framebuffer

View file

@ -16,7 +16,8 @@ public sealed class GameWindow :
IDisposable, IDisposable,
IGameWindowPlatformPublication<GL, IInputContext>, IGameWindowPlatformPublication<GL, IInputContext>,
IGameWindowHostInputCameraPublication, IGameWindowHostInputCameraPublication,
IGameWindowContentEffectsAudioPublication IGameWindowContentEffectsAudioPublication,
IGameWindowSettingsDevToolsPublication
{ {
private static double ClientTimerNow() => private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp() System.Diagnostics.Stopwatch.GetTimestamp()
@ -33,7 +34,6 @@ public sealed class GameWindow :
private SilkWindowCallbackBinding? _windowCallbacks; private SilkWindowCallbackBinding? _windowCallbacks;
private GL? _gl; private GL? _gl;
private IInputContext? _input; private IInputContext? _input;
private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext;
private TerrainModernRenderer? _terrain; private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by /// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary> /// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
@ -345,10 +345,9 @@ public sealed class GameWindow :
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1. // 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. // 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.DevToolsFramePresenter? _devToolsFramePresenter;
private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu;
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus; private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
private DevToolsCompositionOwner? _devToolsComposition;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm; private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. // 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.LocalPlayerSkillState _localPlayerSkills = new();
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
private readonly FramebufferResizeController _framebufferResize; private readonly FramebufferResizeController _framebufferResize;
private IFramebufferDevToolsTarget? _framebufferDevToolsTarget;
private AcDream.App.Input.PlayerModeController? _playerModeController; private AcDream.App.Input.PlayerModeController? _playerModeController;
private readonly AcDream.App.Interaction.PlayerApproachCompletionState private readonly AcDream.App.Interaction.PlayerApproachCompletionState
_playerApproachCompletions = new(); _playerApproachCompletions = new();
@ -784,6 +782,26 @@ public sealed class GameWindow :
_audioSink = value.HookSink; _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<T>( private static void PublishCompositionOwner<T>(
ref T? destination, ref T? destination,
T value, T value,
@ -856,6 +874,64 @@ public sealed class GameWindow :
Console.Error.WriteLine), Console.Error.WriteLine),
this).Compose(platform, hostInputCamera); 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.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.DepthTest);
@ -906,222 +982,6 @@ public sealed class GameWindow :
Console.WriteLine("world-hud font: no system monospace font found"); 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; uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@ -1755,6 +1615,7 @@ public sealed class GameWindow :
wbSpawnAdapter, wbSpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock, onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator); entityScriptActivator: entityScriptActivator);
_devToolsComposition?.LateBindings.WorldEntities.Bind(_worldState);
_liveEntities = new AcDream.App.World.LiveEntityRuntime( _liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState, _worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle( new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
@ -2117,6 +1978,8 @@ public sealed class GameWindow :
_renderDiagnosticLog, _renderDiagnosticLog,
_options.UiProbeDump, _options.UiProbeDump,
resourceDiagnostics); resourceDiagnostics);
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind(
_renderFrameDiagnostics);
// Apply radii from the same immutable quality snapshot used for the // Apply radii from the same immutable quality snapshot used for the
// window's MSAA, terrain anisotropy, and dispatcher A2C setup. // window's MSAA, terrain anisotropy, and dispatcher A2C setup.
@ -2498,7 +2361,8 @@ public sealed class GameWindow :
_movementTruthDiagnostics, _movementTruthDiagnostics,
_localPlayerSkills, _localPlayerSkills,
_viewportAspect); _viewportAspect);
_devToolsCameraMenu?.Bind(_playerModeController); _devToolsComposition?.LateBindings.PlayerModeCommands.Bind(
_playerModeController);
_devToolsCommandBus?.Bind(_liveSessionController); _devToolsCommandBus?.Bind(_liveSessionController);
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
new AcDream.App.Input.LivePlayerModeAutoEntryContext( new AcDream.App.Input.LivePlayerModeAutoEntryContext(
@ -3216,59 +3080,6 @@ public sealed class GameWindow :
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have // EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3). // 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.
/// <summary>Player-mode-aware position source for the DebugPanel.</summary>
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;
}
/// <summary>Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise.</summary>
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() => private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne; _playerMode && _playerController is not null && !_playerController.IsAirborne;
@ -3419,7 +3230,7 @@ public sealed class GameWindow :
new("mouse source", () => _mouseSource?.Deactivate()), new("mouse source", () => _mouseSource?.Deactivate()),
new("keyboard source", () => _kbSource?.Deactivate()), new("keyboard source", () => _kbSource?.Deactivate()),
new("retained UI input", _retailUiLease.QuiesceInput), 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 // Live-session reset retires the complete streaming window. Every
// reset callback owner, especially LandblockStreamer, must remain live // reset callback owner, especially LandblockStreamer, must remain live
@ -3473,7 +3284,7 @@ public sealed class GameWindow :
_gameplayInputActions = null; _gameplayInputActions = null;
}), }),
new("retained UI input", _retailUiLease.DeactivateInput), new("retained UI input", _retailUiLease.DeactivateInput),
new("developer tools input", () => _devToolsInputContext?.Dispose()), new("developer tools input", () => _devToolsComposition?.DetachInput()),
new("camera pointer", () => new("camera pointer", () =>
{ {
AcDream.App.Input.CameraPointerInputController? pointer = AcDream.App.Input.CameraPointerInputController? pointer =
@ -3650,17 +3461,17 @@ public sealed class GameWindow :
[ [
new("developer tools", () => new("developer tools", () =>
{ {
if (_framebufferDevToolsTarget is { } framebufferTarget) DevToolsCompositionOwner? owner = _devToolsComposition;
{ if (owner is null)
_framebufferResize.UnbindDevTools(framebufferTarget); return;
_framebufferDevToolsTarget = null; owner.DisposeFrontend();
} if (!owner.IsDisposalComplete)
throw new InvalidOperationException(
"Developer-tools cleanup remains incomplete.");
_devToolsComposition = null;
_devToolsFramePresenter = null; _devToolsFramePresenter = null;
_devToolsCameraMenu = null;
_devToolsCommandBus = null; _devToolsCommandBus = null;
_devToolsBackend?.Dispose(); _debugVm = null;
_devToolsBackend = null;
_devToolsInputContext = null;
}), }),
new("portal tunnel", () => new("portal tunnel", () =>
{ {

View file

@ -137,7 +137,9 @@ internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
/// Publishes post-presentation frame statistics. The caller invokes this only after /// Publishes post-presentation frame statistics. The caller invokes this only after
/// screenshot capture, preserving the accepted screenshot-before-title/resource order. /// screenshot capture, preserving the accepted screenshot-before-title/resource order.
/// </summary> /// </summary>
internal sealed class RenderFrameDiagnosticsController : IRenderFrameDiagnosticsPhase internal sealed class RenderFrameDiagnosticsController :
IRenderFrameDiagnosticsPhase,
IRenderFrameDiagnosticsSnapshotSource
{ {
internal const double PublicationIntervalSeconds = 0.5; internal const double PublicationIntervalSeconds = 0.5;

View file

@ -0,0 +1,45 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Settings;
internal interface IRuntimeKeyBindingTarget
{
void Apply(KeyBindings bindings);
}
/// <summary>
/// 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.
/// </summary>
internal sealed class RuntimeKeyBindingTarget : IRuntimeKeyBindingTarget
{
private readonly InputDispatcher _dispatcher;
private readonly string _path;
private readonly Action<string> _log;
public RuntimeKeyBindingTarget(
InputDispatcher dispatcher,
string? path = null,
Action<string>? 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}");
}
}
}

View file

@ -79,6 +79,34 @@ internal sealed record RuntimeSettingsSnapshot(
CharacterSettings Character, CharacterSettings Character,
QualitySettings Quality); QualitySettings Quality);
/// <summary>
/// Expected-owner lease for the optional developer settings view model.
/// Failed optional composition can withdraw only the instance it installed.
/// </summary>
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 internal interface IRuntimeSettingsStartupTarget
{ {
void ApplyDisplay(DisplaySettings display); void ApplyDisplay(DisplaySettings display);
@ -248,6 +276,14 @@ internal sealed class RuntimeSettingsController :
return _viewModel; return _viewModel;
} }
public RuntimeSettingsViewModelBinding CreateViewModelBinding(
KeyBindings persistedBindings,
InputDispatcher dispatcher,
Action<KeyBindings> saveBindings) =>
new(
this,
CreateViewModel(persistedBindings, dispatcher, saveBindings));
public void UnbindViewModel(SettingsVM? expected = null) public void UnbindViewModel(SettingsVM? expected = null)
{ {
if (expected is null || ReferenceEquals(_viewModel, expected)) if (expected is null || ReferenceEquals(_viewModel, expected))

View file

@ -21,13 +21,14 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// unchanged transcript does not require a queue snapshot each frame. /// unchanged transcript does not require a queue snapshot each frame.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class ChatVM public sealed class ChatVM : IDisposable
{ {
/// <summary>Default number of tail entries rendered.</summary> /// <summary>Default number of tail entries rendered.</summary>
public const int DefaultDisplayLimit = 20; public const int DefaultDisplayLimit = 20;
private readonly ChatLog _log; private readonly ChatLog _log;
private readonly int _displayLimit; private readonly int _displayLimit;
private bool _disposed;
/// <summary> /// <summary>
/// Sender name of the most recent INCOMING Tell. Drives the /// Sender name of the most recent INCOMING Tell. Drives the
@ -104,6 +105,14 @@ public sealed class ChatVM
LastOutgoingTellTarget = entry.Sender; LastOutgoingTellTarget = entry.Sender;
} }
public void Dispose()
{
if (_disposed)
return;
_log.EntryAppended -= OnEntryAppended;
_disposed = true;
}
/// <summary> /// <summary>
/// Append a client-side system line to the chat log. Used by /// Append a client-side system line to the chat log. Used by
/// client-handled commands (/help, /clear, future) to surface /// client-handled commands (/help, /clear, future) to surface

View file

@ -67,7 +67,7 @@ public readonly record struct ToastMessage(
/// debug panels appear. /// debug panels appear.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class DebugVM public sealed class DebugVM : IDisposable
{ {
/// <summary>Maximum number of combat-event lines kept in the ring.</summary> /// <summary>Maximum number of combat-event lines kept in the ring.</summary>
public const int MaxCombatEvents = 25; public const int MaxCombatEvents = 25;
@ -103,6 +103,8 @@ public sealed class DebugVM
private readonly Func<int> _getParticleCount; private readonly Func<int> _getParticleCount;
private readonly Func<float> _getFps; private readonly Func<float> _getFps;
private readonly Func<float> _getFrameMs; private readonly Func<float> _getFrameMs;
private readonly CombatState _combat;
private bool _disposed;
private readonly Queue<CombatEventLine> _combatEvents = new(); private readonly Queue<CombatEventLine> _combatEvents = new();
private readonly Queue<ToastMessage> _toasts = new(); private readonly Queue<ToastMessage> _toasts = new();
@ -144,7 +146,7 @@ public sealed class DebugVM
Func<float> getFrameMs, Func<float> getFrameMs,
CombatState combat) 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)); _getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition));
_getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg)); _getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg));
_getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId)); _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 // Self-subscribe to combat events. Each one becomes a typed entry
// in the ring; the panel renders them in TextColored. Replaces // in the ring; the panel renders them in TextColored. Replaces
// the old DebugOverlay.BindCombat side-channel. // the old DebugOverlay.BindCombat side-channel.
combat.DamageTaken += d => Push(CombatEventKind.Error, _combat.DamageTaken += OnDamageTaken;
$"<< {d.AttackerName} hit you for {d.Damage}{(d.Critical ? " CRIT!" : "")}"); _combat.DamageDealtAccepted += OnDamageDealt;
combat.DamageDealtAccepted += d => Push(CombatEventKind.Info, _combat.EvadedIncoming += OnEvadedIncoming;
$">> you hit {d.DefenderName} for {d.Damage}"); _combat.MissedOutgoing += OnMissedOutgoing;
combat.EvadedIncoming += attacker => Push(CombatEventKind.Warn, _combat.AttackDone += OnAttackDone;
$"<< {attacker}'s attack missed you"); _combat.KillLanded += OnKillLanded;
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}");
} }
// ── Read-through value surfaces ─────────────────────────────────── // ── Read-through value surfaces ───────────────────────────────────
@ -510,4 +503,46 @@ public sealed class DebugVM
while (_combatEvents.Count > MaxCombatEvents) while (_combatEvents.Count > MaxCombatEvents)
_combatEvents.Dequeue(); _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}");
} }

View file

@ -28,7 +28,31 @@ namespace AcDream.UI.ImGui;
/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25. /// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class ImGuiBootstrapper : IDisposable public interface IImGuiBootstrapper : IDisposable
{
void BeginFrame(float deltaSeconds);
void Render();
void AbortFrame();
}
/// <summary>
/// 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.
/// </summary>
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; private readonly ImGuiController _controller;
@ -42,7 +66,21 @@ public sealed class ImGuiBootstrapper : IDisposable
// - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL) // - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL)
// - Keyboard + mouse event subscription (bound to Silk.NET IInputContext) // - Keyboard + mouse event subscription (bound to Silk.NET IInputContext)
// - Default style = dark // - 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);
}
} }
/// <summary> /// <summary>

View file

@ -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());
}

View file

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

View file

@ -283,6 +283,7 @@ public sealed class DevToolsFramePresenterTests
private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend
{ {
public void Dispose() { }
public HashSet<string> OpenMenus { get; } = []; public HashSet<string> OpenMenus { get; } = [];
public HashSet<string> ClickedItems { get; } = []; public HashSet<string> ClickedItems { get; } = [];
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];

View file

@ -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<ObjectDisposedException>(() => source.Bind(first));
}
[Fact]
public void FrameDiagnosticsRejectsACompetingCanonicalOwner()
{
var source = new DeferredRenderFrameDiagnosticsSource();
source.Bind(new FrameSource(60, 16.7));
Assert.Throws<InvalidOperationException>(() =>
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<ObjectDisposedException>(() => 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<InvalidOperationException>(() => source.Bind(other));
source.Unbind(first);
source.Bind(other);
source.Deactivate();
Assert.Equal(0, source.EntityCount);
Assert.Throws<ObjectDisposedException>(() => 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++;
}
}

View file

@ -57,6 +57,22 @@ public sealed class FramebufferResizeControllerTests
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls); Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
} }
[Fact]
public void DevToolsBindingReleasesExpectedTargetWithoutReplayingResize()
{
var calls = new List<string>();
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<string> calls) : IFramebufferViewportTarget private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
{ {
public void ResizeViewport(int width, int height) => public void ResizeViewport(int width, int height) =>

View file

@ -94,7 +94,13 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); 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.RenderFrameResourceController(", source);
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source); Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
@ -119,7 +125,7 @@ public sealed class GameWindowRenderLeafCompositionTests
"new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"", "new ResourceShutdownStage(\"render frontends\"",
"new(\"developer tools\"", "new(\"developer tools\"",
"_devToolsBackend?.Dispose()", "owner.DisposeFrontend()",
"new(\"portal tunnel\"", "new(\"portal tunnel\"",
"new(\"paperdoll viewport\"", "new(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\""); "new ResourceShutdownStage(\"OpenGL context\"");
@ -135,7 +141,7 @@ public sealed class GameWindowRenderLeafCompositionTests
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"_frameGraphs.Withdraw();", "_frameGraphs.Withdraw();",
"_devToolsBackend?.Dispose()", "owner.DisposeFrontend()",
"new ResourceShutdownStage(\"input\"", "new ResourceShutdownStage(\"input\"",
"_input?.Dispose();", "_input?.Dispose();",
"new ResourceShutdownStage(\"OpenGL context\""); "new ResourceShutdownStage(\"OpenGL context\"");

View file

@ -56,8 +56,8 @@ public sealed class GameWindowSlice8BoundaryTests
"this).Compose(platform);", "this).Compose(platform);",
"new ContentEffectsAudioCompositionPhase(", "new ContentEffectsAudioCompositionPhase(",
"this).Compose(platform, hostInputCamera);", "this).Compose(platform, hostInputCamera);",
"_runtimeSettings.ApplyStartup(", "new SettingsDevToolsCompositionPhase(",
"new RuntimeSettingsStartupTargets(", "this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"_uiHost = _retailUiLease.AcquireHost(", "_uiHost = _retailUiLease.AcquireHost(",
"_uiHost.WireMouse(m)", "_uiHost.WireMouse(m)",
"_uiHost.WireKeyboard(kb)", "_uiHost.WireKeyboard(kb)",
@ -138,10 +138,19 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal); Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void CycleWeather()", 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( AssertAppearsInOrder(
load, load,
"_debugVm.CycleTimeOfDay =",
"_runtimeDiagnosticCommands.CycleTimeOfDay;",
"new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(", "new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(",
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);"); "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);");
} }
@ -267,11 +276,21 @@ public sealed class GameWindowSlice8BoundaryTests
"Window.Create(options)"); "Window.Create(options)");
AssertAppearsInOrder( AssertAppearsInOrder(
load, load,
"_runtimeSettings.ApplyStartup(", "new SettingsDevToolsCompositionPhase(",
"if (DevToolsEnabled)", "this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"TerrainAtlas.Build(", "TerrainAtlas.Build(",
"_runtimeSettings.BindRuntimeTargets(", "_runtimeSettings.BindRuntimeTargets(",
"_liveSessionHost.Start(_options)"); "_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( AssertAppearsInOrder(
shutdown, shutdown,
"_runtimeSettings.UnbindViewModel()", "_runtimeSettings.UnbindViewModel()",

View file

@ -81,6 +81,31 @@ public sealed class RuntimeSettingsControllerTests
controller.BindRuntimeTargets(new FakeRuntimeTargets(events))); 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<InvalidOperationException>(() =>
controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { }));
second.Dispose();
}
[Fact] [Fact]
public void StartupRetryResumesAfterLastSuccessfulStage() public void StartupRetryResumesAfterLastSuccessfulStage()
{ {

View file

@ -12,6 +12,20 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// </summary> /// </summary>
public sealed class ChatVMLastTellSenderTests 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] [Fact]
public void LastIncomingTellSender_StartsNull() public void LastIncomingTellSender_StartsNull()
{ {

View file

@ -14,6 +14,22 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
/// </summary> /// </summary>
public sealed class DebugVMTests 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);
}
/// <summary> /// <summary>
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every /// Build a minimal <see cref="DebugVM"/> with safe defaults for every
/// constructor source. Tests that don't care about a particular source /// constructor source. Tests that don't care about a particular source