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

@ -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;