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

@ -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", () =>
{