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
{
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; }
public void Show(string message) => ViewModel?.AddToast(message);
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel;
public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
{
ArgumentNullException.ThrowIfNull(viewModel);
if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel))
throw new InvalidOperationException(
"Combat feedback is already bound to a developer view model.");
_viewModel = viewModel;
}
public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
{
ArgumentNullException.ThrowIfNull(viewModel);
if (ReferenceEquals(_viewModel, viewModel))
_viewModel = null;
}
public void Show(string message) => _viewModel?.AddToast(message);
}
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations

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
/// those slots directly.
/// </summary>
internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations
internal sealed class PlayerModeController :
ILocalPlayerTeleportModeOperations,
IDevToolsPlayerModeTarget
{
private readonly LocalPlayerModeState _mode;
private readonly LocalPlayerControllerSlot _controllerSlot;

View file

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

View file

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

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);
}
/// <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>
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue
/// to come from Window.Size; this owner only applies physical framebuffer

View file

@ -16,7 +16,8 @@ public sealed class GameWindow :
IDisposable,
IGameWindowPlatformPublication<GL, IInputContext>,
IGameWindowHostInputCameraPublication,
IGameWindowContentEffectsAudioPublication
IGameWindowContentEffectsAudioPublication,
IGameWindowSettingsDevToolsPublication
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
@ -33,7 +34,6 @@ public sealed class GameWindow :
private SilkWindowCallbackBinding? _windowCallbacks;
private GL? _gl;
private IInputContext? _input;
private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext;
private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <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.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.App.Rendering.ImGuiDevToolsFrameBackend? _devToolsBackend;
private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu;
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
private DevToolsCompositionOwner? _devToolsComposition;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
@ -434,7 +433,6 @@ public sealed class GameWindow :
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
private readonly FramebufferResizeController _framebufferResize;
private IFramebufferDevToolsTarget? _framebufferDevToolsTarget;
private AcDream.App.Input.PlayerModeController? _playerModeController;
private readonly AcDream.App.Interaction.PlayerApproachCompletionState
_playerApproachCompletions = new();
@ -784,6 +782,26 @@ public sealed class GameWindow :
_audioSink = value.HookSink;
}
void IGameWindowSettingsDevToolsPublication.PublishDevTools(
DevToolsCompositionOwner value)
{
ArgumentNullException.ThrowIfNull(value);
if (_devToolsComposition is not null
|| _devToolsFramePresenter is not null
|| _devToolsCommandBus is not null
|| _debugVm is not null)
{
throw new InvalidOperationException(
"The GameWindow composition shell already owns developer tools.");
}
_devToolsComposition = value;
_devToolsFramePresenter = value.Presenter;
_devToolsCommandBus = value.CommandBus;
_vitalsVm = value.Vitals;
_debugVm = value.Debug;
}
private static void PublishCompositionOwner<T>(
ref T? destination,
T value,
@ -856,6 +874,64 @@ public sealed class GameWindow :
Console.Error.WriteLine),
this).Compose(platform, hostInputCamera);
SettingsDevToolsOptionalDependencies? optionalDevTools = null;
if (DevToolsEnabled)
{
var devToolsWorldEntities =
new DeferredCanonicalWorldEntityCountSource();
var devToolsFrameDiagnostics =
new DeferredRenderFrameDiagnosticsSource();
var devToolsPlayerModeCommands =
new DeferredDevToolsPlayerModeCommands();
var devToolsFacts = new DevToolsRuntimeFacts(
_localPlayerMode,
_playerControllerSlot,
hostInputCamera.CameraController,
devToolsWorldEntities,
_animatedEntities,
_debugVmRenderFacts,
_physicsEngine,
_worldSceneDebugState,
_renderRange,
hostInputCamera.CameraPointerInput,
_worldEnvironment,
Lighting,
contentEffectsAudio.ParticleSystem,
devToolsFrameDiagnostics);
IRuntimeKeyBindingTarget? keyBindingTarget =
hostInputCamera.InputDispatcher is { } settingsDispatcher
? new RuntimeKeyBindingTarget(settingsDispatcher)
: null;
optionalDevTools = new SettingsDevToolsOptionalDependencies(
devToolsFacts,
keyBindingTarget,
devToolsWorldEntities,
devToolsFrameDiagnostics,
devToolsPlayerModeCommands);
}
SettingsDevToolsResult settingsDevTools =
new SettingsDevToolsCompositionPhase(
new SettingsDevToolsDependencies(
_window!,
_runtimeSettings,
new RuntimeSettingsStartupTargets(
new SilkRuntimeDisplayWindowTarget(_window!),
_displayFramePacing,
hostInputCamera.CameraController,
contentEffectsAudio.Audio?.Engine),
_hostQuiescence,
Chat,
Combat,
LocalPlayer,
optionalDevTools,
_runtimeDiagnosticCommands,
_combatFeedback,
_keyBindings,
_frameProfiler,
_framebufferResize,
Console.WriteLine),
this).Compose(platform, hostInputCamera, contentEffectsAudio);
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest);
@ -906,222 +982,6 @@ public sealed class GameWindow :
Console.WriteLine("world-hud font: no system monospace font found");
}
// Settings are runtime state, not devtools state. Apply the immutable
// pre-window snapshot once after camera/audio exist and before any
// optional frontend or world/render factory consumes it.
_runtimeSettings.ApplyStartup(
new RuntimeSettingsStartupTargets(
new SilkRuntimeDisplayWindowTarget(_window!),
_displayFramePacing,
_cameraController!,
_audioEngine));
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
if (DevToolsEnabled)
{
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null;
try
{
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
_input!,
_hostQuiescence);
imguiBootstrap =
new AcDream.UI.ImGui.ImGuiBootstrapper(
_gl!,
_window!,
_devToolsInputContext);
_devToolsInputContext.Activate();
var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
// bars surface only after the first PlayerDescription has
// populated LocalPlayer (Issue #5).
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
var vitalsPanel =
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm);
panelHost.Register(vitalsPanel);
// ChatPanel: reads the tail of the shared ChatLog. No GUID
// dependency — works pre-login (empty) and post-login (live
// tail of received speech/tells/channels/system msgs).
// FpsProvider + PositionProvider plumb the runtime state
// the client-side /framerate and /loc commands need; the
// panel asks the VM, the VM asks GameWindow via these
// delegates, no panel-vs-renderer-vs-state coupling.
var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat)
{
FpsProvider = () =>
(float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
PositionProvider = () => GetDebugPlayerPosition(),
};
var chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm);
panelHost.Register(chatPanel);
// Phase I.2: DebugPanel — replaces the deleted custom
// DebugOverlay (six floating panels + hint bar + toast).
// The VM closes over every data source the old snapshot
// record exposed; reads are live (no per-frame snapshot
// build). Action hooks tie the panel's cycle/toggle
// buttons back to the same routines the F2/F7/F10
// keybinds use.
_debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM(
getPlayerPosition: () => GetDebugPlayerPosition(),
getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(),
getPlayerCellId: () => GetDebugPlayerCellId(),
getPlayerOnGround: () => GetDebugPlayerOnGround(),
getInPlayerMode: () => _playerMode,
getInFlyMode: () => _cameraController?.IsFlyMode ?? false,
getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f,
getEntityCount: () => _worldState.Entities.Count,
getAnimatedCount: () => _animatedEntities.Count,
getLandblocksVisible: () =>
_debugVmRenderFacts.DebugVmFacts.VisibleLandblocks,
getLandblocksTotal: () =>
_debugVmRenderFacts.DebugVmFacts.TotalLandblocks,
getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered,
getNearestObjDist: () =>
_debugVmRenderFacts.DebugVmFacts.NearestObjectDistance,
getNearestObjLabel: () =>
_debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
getColliding: () =>
_debugVmRenderFacts.DebugVmFacts.Colliding,
getDebugWireframes: () =>
_worldSceneDebugState.CollisionWireframesVisible,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
getMouseSensitivity: () =>
_cameraPointerInput?.ActiveSensitivity ?? 1f,
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
getRmbOrbit: () =>
_cameraPointerInput?.RmbOrbitHeld ?? false,
getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(),
getDayFraction: () => (float)WorldTime.DayFraction,
getWeather: () => Weather.Kind.ToString(),
getActiveLights: () => Lighting.ActiveCount,
getRegisteredLights: () => Lighting.RegisteredCount,
getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0,
getFps: () =>
(float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
getFrameMs: () =>
(float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
combat: Combat);
_debugVm.CycleTimeOfDay =
_runtimeDiagnosticCommands.CycleTimeOfDay;
_debugVm.CycleWeather =
_runtimeDiagnosticCommands.CycleWeather;
_debugVm.ToggleCollisionWires =
_runtimeDiagnosticCommands.ToggleCollisionWireframes;
// Phase K.2: free-fly toggle button — same routine the
// legacy F-key alias hits. Cancels the one-shot
// auto-entry if the user opts out of player mode before
// it fires, so the chase camera doesn't snap on top of
// the fly camera mid-inspection.
_debugVm.ToggleFlyMode = () =>
_playerModeController?.ToggleFlyOrChase();
_combatFeedback.ViewModel = _debugVm;
var debugPanel =
new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
panelHost.Register(debugPanel);
// Phase K.3 — Settings panel. SettingsVM owns a draft
// copy of the active KeyBindings. Save replaces the
// dispatcher's live table + writes JSON; Cancel reverts
// the draft. Construction is null-safe vs. the
// dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field).
AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null;
if (_inputDispatcher is not null)
{
settingsVm = _runtimeSettings.CreateViewModel(
_keyBindings,
_inputDispatcher,
bindings =>
{
_inputDispatcher.SetBindings(bindings);
try
{
bindings.SaveToFile(
AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
Console.WriteLine(
"keybinds: saved to "
+ AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"keybinds: save failed: {ex.Message}");
}
});
settingsPanel =
new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm);
panelHost.Register(settingsPanel);
}
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
// L.0 Display tab: seed sensible default positions for
// every registered panel. cond=FirstUseEver means imgui.ini
// takes precedence on subsequent launches — the user's
// dragged positions persist. Without this, the first-run
// experience stacks every panel at (0,0) which looks
// broken.
_devToolsBackend = new AcDream.App.Rendering.ImGuiDevToolsFrameBackend(
imguiBootstrap,
panelHost);
_devToolsCameraMenu =
new AcDream.App.Rendering.DevToolsCameraMenuOperations(
_cameraController!);
_devToolsCommandBus =
new AcDream.App.Rendering.DevToolsCommandBusSource();
_devToolsFramePresenter =
new AcDream.App.Rendering.DevToolsFramePresenter(
_devToolsBackend,
_devToolsCameraMenu,
_devToolsCommandBus,
_frameProfiler,
new AcDream.App.Rendering.DevToolsPanelSet(
vitalsPanel,
chatPanel,
debugPanel,
_debugVm,
settingsPanel));
_framebufferDevToolsTarget = new DevToolsFramebufferTarget(
_devToolsFramePresenter);
_framebufferResize.BindDevTools(_framebufferDevToolsTarget);
_devToolsFramePresenter.ResetLayout(
_window!.Size.X,
_window.Size.Y,
AcDream.App.Rendering.DevToolsPanelLayoutCondition.FirstUseEver);
}
catch (Exception ex)
{
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
// The focused backend borrows the bootstrap during normal
// operation. Construction failure still owns this local and
// must release it before abandoning the optional frontend.
imguiBootstrap?.Dispose();
if (_framebufferDevToolsTarget is { } framebufferTarget)
{
_framebufferResize.UnbindDevTools(framebufferTarget);
_framebufferDevToolsTarget = null;
}
_devToolsInputContext?.Dispose();
_devToolsInputContext = null;
_devToolsBackend = null;
_devToolsFramePresenter = null;
_devToolsCameraMenu = null;
_devToolsCommandBus = null;
_vitalsVm = null;
_combatFeedback.ViewModel = null;
_debugVm = null;
_runtimeSettings.UnbindViewModel(settingsVm);
}
}
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@ -1755,6 +1615,7 @@ public sealed class GameWindow :
wbSpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
_devToolsComposition?.LateBindings.WorldEntities.Bind(_worldState);
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
@ -2117,6 +1978,8 @@ public sealed class GameWindow :
_renderDiagnosticLog,
_options.UiProbeDump,
resourceDiagnostics);
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind(
_renderFrameDiagnostics);
// Apply radii from the same immutable quality snapshot used for the
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
@ -2498,7 +2361,8 @@ public sealed class GameWindow :
_movementTruthDiagnostics,
_localPlayerSkills,
_viewportAspect);
_devToolsCameraMenu?.Bind(_playerModeController);
_devToolsComposition?.LateBindings.PlayerModeCommands.Bind(
_playerModeController);
_devToolsCommandBus?.Bind(_liveSessionController);
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
@ -3216,59 +3080,6 @@ public sealed class GameWindow :
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
//
// The ImGui DebugPanel reads through DebugVM closures that ask
// GameWindow for live state on every frame. The helper methods below
// are the *named* targets of those closures (and of the F-key
// shortcuts that share the same actions). Keeping them as methods
// (vs ad-hoc lambdas where the VM is constructed) means both the
// panel button and the keybind run the *same* code, so behavior
// can't drift between the two surfaces.
/// <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() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
@ -3419,7 +3230,7 @@ public sealed class GameWindow :
new("mouse source", () => _mouseSource?.Deactivate()),
new("keyboard source", () => _kbSource?.Deactivate()),
new("retained UI input", _retailUiLease.QuiesceInput),
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
new("developer tools input", () => _devToolsComposition?.DeactivateInput()),
]),
// Live-session reset retires the complete streaming window. Every
// reset callback owner, especially LandblockStreamer, must remain live
@ -3473,7 +3284,7 @@ public sealed class GameWindow :
_gameplayInputActions = null;
}),
new("retained UI input", _retailUiLease.DeactivateInput),
new("developer tools input", () => _devToolsInputContext?.Dispose()),
new("developer tools input", () => _devToolsComposition?.DetachInput()),
new("camera pointer", () =>
{
AcDream.App.Input.CameraPointerInputController? pointer =
@ -3650,17 +3461,17 @@ public sealed class GameWindow :
[
new("developer tools", () =>
{
if (_framebufferDevToolsTarget is { } framebufferTarget)
{
_framebufferResize.UnbindDevTools(framebufferTarget);
_framebufferDevToolsTarget = null;
}
DevToolsCompositionOwner? owner = _devToolsComposition;
if (owner is null)
return;
owner.DisposeFrontend();
if (!owner.IsDisposalComplete)
throw new InvalidOperationException(
"Developer-tools cleanup remains incomplete.");
_devToolsComposition = null;
_devToolsFramePresenter = null;
_devToolsCameraMenu = null;
_devToolsCommandBus = null;
_devToolsBackend?.Dispose();
_devToolsBackend = null;
_devToolsInputContext = null;
_debugVm = null;
}),
new("portal tunnel", () =>
{

View file

@ -137,7 +137,9 @@ internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
/// Publishes post-presentation frame statistics. The caller invokes this only after
/// screenshot capture, preserving the accepted screenshot-before-title/resource order.
/// </summary>
internal sealed class RenderFrameDiagnosticsController : IRenderFrameDiagnosticsPhase
internal sealed class RenderFrameDiagnosticsController :
IRenderFrameDiagnosticsPhase,
IRenderFrameDiagnosticsSnapshotSource
{
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,
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
{
void ApplyDisplay(DisplaySettings display);
@ -248,6 +276,14 @@ internal sealed class RuntimeSettingsController :
return _viewModel;
}
public RuntimeSettingsViewModelBinding CreateViewModelBinding(
KeyBindings persistedBindings,
InputDispatcher dispatcher,
Action<KeyBindings> saveBindings) =>
new(
this,
CreateViewModel(persistedBindings, dispatcher, saveBindings));
public void UnbindViewModel(SettingsVM? expected = null)
{
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.
/// </para>
/// </summary>
public sealed class ChatVM
public sealed class ChatVM : IDisposable
{
/// <summary>Default number of tail entries rendered.</summary>
public const int DefaultDisplayLimit = 20;
private readonly ChatLog _log;
private readonly int _displayLimit;
private bool _disposed;
/// <summary>
/// Sender name of the most recent INCOMING Tell. Drives the
@ -104,6 +105,14 @@ public sealed class ChatVM
LastOutgoingTellTarget = entry.Sender;
}
public void Dispose()
{
if (_disposed)
return;
_log.EntryAppended -= OnEntryAppended;
_disposed = true;
}
/// <summary>
/// Append a client-side system line to the chat log. Used by
/// client-handled commands (/help, /clear, future) to surface

View file

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

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.
/// </para>
/// </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;
@ -42,7 +66,21 @@ public sealed class ImGuiBootstrapper : IDisposable
// - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL)
// - Keyboard + mouse event subscription (bound to Silk.NET IInputContext)
// - Default style = dark
_controller = new ImGuiController(gl, window, input);
nint priorContext = ImGuiNET.ImGui.GetCurrentContext();
try
{
_controller = new ImGuiController(gl, window, input);
}
catch (Exception failure)
{
nint partialContext = ImGuiNET.ImGui.GetCurrentContext();
if (partialContext != 0 && partialContext != priorContext)
{
ImGuiNET.ImGui.DestroyContext(partialContext);
ImGuiNET.ImGui.SetCurrentContext(priorContext);
}
throw new ImGuiBootstrapperConstructionException(failure);
}
}
/// <summary>