feat(ui): #15 migrate DebugOverlay to ImGui DebugPanel - 7 collapsing sections + diagnostics toggles

Replaces the 473-LOC custom-StbTrueTypeSharp DebugOverlay with an
ImGui-rendered DebugPanel using the I.1 widget extensions. Single
window with 7 CollapsingHeader sections; checkboxes are the primary
toggle surface; F-keys retained where they invoke real gameplay
actions, dropped where they only toggled panels.

Pieces:
- DebugVM (UI.Abstractions): read-through ViewModel with combat-event
  ring (cap 25), toast ring (cap 25), 4 diagnostic-flag bools
  (DumpMotion / DumpVitals / DumpOpcodes / DumpSky), 3 Action hooks
  (CycleTimeOfDay / CycleWeather / ToggleCollisionWires). Self-
  subscribes to CombatState.DamageTaken/DealtAccepted/Evaded* /
  Missed*/AttackDone/KillLanded - replaces the old BindCombat path.
- DebugPanel (UI.Abstractions): one ImGui window with sections
  Player Info, Performance, Compass (text-only - draw-list strip
  deferred to D.6), Help (BeginTable cheat-sheet), Combat events
  (TextColored by kind: Info=yellow, Warning=red, Error=deep red),
  Recent toasts, Diagnostics (Checkboxes for the 4 flags + Buttons
  for the 3 cycle/toggle actions).
- All 28 Snapshot data points covered: Fps, FrameMs, PlayerPos,
  HeadingDeg, CellId, OnGround, InPlayerMode, InFlyMode,
  VerticalVelocity, EntityCount, AnimatedCount, LandblocksVisible,
  LandblocksTotal, ShadowObjectCount, NearestObjDist, NearestObjLabel,
  Colliding, DebugWireframes, StreamingRadius, MouseSensitivity,
  ChaseDistance, RmbOrbit, HourName, DayFraction, Weather,
  ActiveLights, RegisteredLights, ParticleCount.
- GameWindow surgery (+252/-165): removed _debugOverlay field +
  snapshot builder block + Update/Draw calls; added _debugVm /
  _debugPanel construction in the if (DevToolsEnabled) block;
  added per-frame nearest-object scan cached for VM closures
  (zero cost when devtools off); helper methods CycleTimeOfDay /
  CycleWeather / ToggleCollisionWires / GetDebug* / GetActiveSensitivity.

F-key disposition:
- F1: repurposed - now toggles whole DebugPanel visibility.
- F2: kept - ToggleCollisionWires (also a Button in panel).
- F4 / F5 / F6: REMOVED - per-section toggles replaced by
  CollapsingHeader inside one window.
- F7: kept - CycleTimeOfDay (also Button).
- F8 / F9: kept - mouse-sensitivity adjust; toasts route to
  _debugVm.AddToast.
- F10: kept - CycleWeather (also Button).

DebugOverlay.cs DELETED (473 LOC). TextRenderer + BitmapFont kept
alive: UiHost references _debugFont and the future HUD-in-world
(D.6) will reuse both.

11 new DebugVM tests covering combat-event-ring subscription, toast
ring cap, diagnostic-flag toggles. UI.Abstractions.Tests: 96 -> 107.
Solution total: 989 green (243 Core.Net + 639 Core + 107 UI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 20:09:26 +02:00
parent 3d26c8efde
commit 56037a4471
5 changed files with 1081 additions and 639 deletions

View file

@ -30,18 +30,29 @@ public sealed class GameWindow : IDisposable
private bool _debugCollisionVisible = true;
private int _debugDrawLogOnce = 0;
// On-screen debug HUD — info panel, stats panel, compass, keybind help.
// F1/F2/F4/F5/F6 toggle the individual panels (see the key handler).
// Null if no system font is available at startup; in that case the HUD
// is silently disabled and the rest of the client keeps working.
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
// TextRenderer + BitmapFont fields stay alive because they're shared
// with UiHost and reserved for the future world-space HUD (D.6 —
// damage floaters, name plates) where ImGui can't reach into the 3D
// scene. They are no longer used for any debug overlay.
private TextRenderer? _textRenderer;
private BitmapFont? _debugFont;
private DebugOverlay? _debugOverlay;
// Last-computed perf values so the HUD always has something to show even
// though the title-bar FPS is only updated every 0.5s.
private double _lastFps = 60.0;
private double _lastFrameMs = 16.7;
// Phase I.2: per-frame counters surfaced through the ImGui DebugPanel
// VM closures. Computed once per render pass alongside the frustum
// walk + nearest-object scan; the VM closures just read the cached
// values. Skipped when DevTools are off (zero cost).
private int _lastVisibleLandblocks;
private int _lastTotalLandblocks;
private float _lastNearestObjDist = float.PositiveInfinity;
private string _lastNearestObjLabel = "-";
private bool _lastColliding;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private readonly AcDream.App.Streaming.GpuWorldState _worldState = new();
@ -301,6 +312,10 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
private static readonly bool DevToolsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
@ -537,85 +552,34 @@ public sealed class GameWindow : IDisposable
}
else if (key == Key.F1)
{
if (_debugOverlay is not null)
// Phase I.2: F1 now toggles the entire ImGui DebugPanel
// visibility. The old per-section toggles (F4/F5/F6) are
// gone — sections are collapsing headers inside the
// single window now.
foreach (var panel in EnumerateDebugPanel())
{
_debugOverlay.ShowHelpPanel = !_debugOverlay.ShowHelpPanel;
_debugOverlay.Toast($"Help {(_debugOverlay.ShowHelpPanel ? "ON" : "OFF")}");
panel.IsVisible = !panel.IsVisible;
_debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
}
}
else if (key == Key.F2)
{
_debugCollisionVisible = !_debugCollisionVisible;
_debugOverlay?.Toast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
else if (key == Key.F4)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowInfoPanel = !_debugOverlay.ShowInfoPanel;
_debugOverlay.Toast($"Info panel {(_debugOverlay.ShowInfoPanel ? "ON" : "OFF")}");
}
}
else if (key == Key.F5)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowStatsPanel = !_debugOverlay.ShowStatsPanel;
_debugOverlay.Toast($"Stats panel {(_debugOverlay.ShowStatsPanel ? "ON" : "OFF")}");
}
}
else if (key == Key.F6)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowCompass = !_debugOverlay.ShowCompass;
_debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}");
}
// Real gameplay toggle — keeps the F2 keybind. Same
// action is wired into the DebugPanel's
// "Toggle collision wires" button via DebugVM.
ToggleCollisionWires();
}
else if (key == Key.F7)
{
// Phase G.1: cycle debug time-of-day overrides. Useful for
// visually verifying the sun arc + keyframe transitions
// without waiting 30+ real-time hours. Cycle order:
// clear debug → 0.0 (midnight) → 0.25 (dawn)
// → 0.5 (noon) → 0.75 (dusk) → clear
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null, // server time
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugOverlay?.Toast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugOverlay?.Toast("Time override cleared");
}
// Phase I.2: keep F7 as a hotkey alias for the
// DebugPanel's "Cycle time of day" button.
CycleTimeOfDay();
}
else if (key == Key.F10)
{
// Phase G.1: cycle weather kinds manually. Useful for
// testing the rain/snow particle systems + storm/light
// fog without waiting for the daily RNG to hit.
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}");
// Phase I.2: keep F10 as a hotkey alias for the
// DebugPanel's "Cycle weather" button.
CycleWeather();
}
else if (key == Key.F8 || key == Key.F9)
{
@ -638,7 +602,7 @@ public sealed class GameWindow : IDisposable
else if (modeLabel == "Fly") _sensFly = next;
else _sensOrbit = next;
_debugOverlay?.Toast($"{modeLabel} sens {next:F3}x");
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
}
else if (key == Key.Escape)
{
@ -857,25 +821,23 @@ public sealed class GameWindow : IDisposable
_debugLines = new DebugLineRenderer(_gl, shadersDir);
// Debug HUD: load a system monospace font and set up the text overlay.
// Skips silently if no font is available (the rest of the client still works).
// Phase I.2: load a system monospace font + TextRenderer for the
// future world-space HUD (D.6). The custom DebugOverlay is gone;
// the ImGui DebugPanel handles all dev surfaces now. These fields
// are reserved for future work — currently unused at the renderer
// level. Skips silently if no font is available.
var fontBytes = BitmapFont.TryLoadSystemMonospaceFont();
if (fontBytes is not null)
{
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
_textRenderer = new TextRenderer(_gl, shadersDir);
_debugOverlay = new DebugOverlay(_textRenderer, _debugFont);
// Phase F.1/H.1/E.4 visibility: show chat + combat events on screen.
_debugOverlay.Chat = Chat;
_debugOverlay.Combat = Combat;
_debugOverlay.BindCombat(Combat);
Console.WriteLine($"debug overlay: loaded {fontBytes.Length / 1024}KB font, " +
Console.WriteLine($"world-hud font: loaded {fontBytes.Length / 1024}KB, " +
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
$"lineHeight={_debugFont.LineHeight:F1}px");
$"lineHeight={_debugFont.LineHeight:F1}px (reserved for D.6 HUD)");
}
else
{
Console.WriteLine("debug overlay: no system monospace font found; HUD disabled");
Console.WriteLine("world-hud font: no system monospace font found");
}
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
@ -959,7 +921,50 @@ public sealed class GameWindow : IDisposable
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm));
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel registered)");
// 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: () => _lastVisibleLandblocks,
getLandblocksTotal: () => _lastTotalLandblocks,
getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered,
getNearestObjDist: () => _lastNearestObjDist,
getNearestObjLabel: () => _lastNearestObjLabel,
getColliding: () => _lastColliding,
getDebugWireframes: () => _debugCollisionVisible,
getStreamingRadius: () => _streamingRadius,
getMouseSensitivity: () => GetActiveSensitivity(),
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
getRmbOrbit: () => _rmbHeld,
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)_lastFps,
getFrameMs: () => (float)_lastFrameMs,
combat: Combat);
_debugVm.CycleTimeOfDay = CycleTimeOfDay;
_debugVm.CycleWeather = CycleWeather;
_debugVm.ToggleCollisionWires = ToggleCollisionWires;
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel registered)");
}
catch (Exception ex)
{
@ -968,6 +973,8 @@ public sealed class GameWindow : IDisposable
_imguiBootstrap = null;
_panelHost = null;
_vitalsVm = null;
_debugVm = null;
_debugPanel = null;
}
}
@ -4119,48 +4126,32 @@ public sealed class GameWindow : IDisposable
visibleLandblocks++;
}
// ── Debug HUD overlay ────────────────────────────────────────────
// Build a per-frame snapshot of state we want to show and hand it
// to the overlay. Drawn after all 3D passes so it sits on top.
if (_debugOverlay is not null && _textRenderer is not null && _debugFont is not null)
// Phase I.2: refresh per-frame fields that DebugVM closures
// can't compute lazily (frustum-derived counters + nearest-
// object scan). Every other DebugVM field reads through to
// the live source via its closure. Skipped entirely when
// devtools are off — avoids the nearest-object O(N) scan in
// the hot path of an offline render.
if (_debugVm is not null)
{
System.Numerics.Vector3 playerPos;
float headingDeg;
uint cellId;
bool onGround;
float vVel;
if (_playerMode && _playerController is not null)
{
playerPos = _playerController.Position;
// Yaw in math convention: 0 = +X east, PI/2 = +Y north.
// Convert to degrees in [0, 360).
headingDeg = _playerController.Yaw * (180f / MathF.PI);
headingDeg %= 360f;
if (headingDeg < 0f) headingDeg += 360f;
cellId = _playerController.CellId;
onGround = !_playerController.IsAirborne;
vVel = _playerController.VerticalVelocity;
}
else
{
playerPos = camPos;
var camFwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
headingDeg = MathF.Atan2(camFwd.Y, camFwd.X) * (180f / MathF.PI);
if (headingDeg < 0f) headingDeg += 360f;
cellId = 0u;
onGround = false;
vVel = 0f;
}
_lastVisibleLandblocks = visibleLandblocks;
_lastTotalLandblocks = totalLandblocks;
// Compute fly/orbit-mode camera position for the nearest-
// object scan when not in player mode.
System.Numerics.Vector3 nearOrigin;
if (_playerMode && _playerController is not null)
nearOrigin = _playerController.Position;
else
nearOrigin = camPos;
// Nearest shadow object — surface-to-surface distance in XY
// (subtract player radius + obj radius). Negative == penetrating.
const float playerRadius = 0.48f;
float bestDist = float.PositiveInfinity;
string bestLabel = "-";
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = obj.Position.X - playerPos.X;
float dy = obj.Position.Y - playerPos.Y;
float dx = obj.Position.X - nearOrigin.X;
float dy = obj.Position.Y - nearOrigin.Y;
float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
if (d < bestDist)
{
@ -4168,53 +4159,9 @@ public sealed class GameWindow : IDisposable
bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
}
}
bool colliding = bestDist < 0.05f;
if (bestDist < 0f) bestDist = 0f;
// Select the active-mode sensitivity to display.
float activeSens;
if (_playerMode && _cameraController?.IsChaseMode == true)
activeSens = _sensChase;
else if (_cameraController?.IsFlyMode == true)
activeSens = _sensFly;
else
activeSens = _sensOrbit;
// Phase G: pull sky + weather + lighting state for the overlay.
var dayCal = WorldTime.CurrentCalendar;
var snapshot = new DebugOverlay.Snapshot(
Fps: (float)_lastFps,
FrameTimeMs: (float)_lastFrameMs,
PlayerPos: playerPos,
HeadingDeg: headingDeg,
CellId: cellId,
OnGround: onGround,
InPlayerMode: _playerMode,
InFlyMode: _cameraController?.IsFlyMode ?? false,
VerticalVelocity: vVel,
EntityCount: _worldState.Entities.Count,
AnimatedCount: _animatedEntities.Count,
LandblocksVisible: visibleLandblocks,
LandblocksTotal: totalLandblocks,
ShadowObjectCount: _physicsEngine.ShadowObjects.TotalRegistered,
NearestObjDist: bestDist,
NearestObjLabel: bestLabel,
Colliding: colliding,
DebugWireframes: _debugCollisionVisible,
StreamingRadius: _streamingRadius,
MouseSensitivity: activeSens,
ChaseDistance: _chaseCamera?.Distance ?? 0f,
RmbOrbit: _rmbHeld,
HourName: dayCal.Hour.ToString(),
DayFraction: (float)WorldTime.DayFraction,
Weather: Weather.Kind.ToString(),
ActiveLights: Lighting.ActiveCount,
RegisteredLights: Lighting.RegisteredCount,
ParticleCount: _particleSystem?.ActiveParticleCount ?? 0);
_debugOverlay.Update((float)deltaSeconds);
var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y);
_debugOverlay.Draw(snapshot, size);
_lastColliding = bestDist < 0.05f;
_lastNearestObjDist = bestDist < 0f ? 0f : bestDist;
_lastNearestObjLabel = bestLabel;
}
}
@ -4939,6 +4886,146 @@ public sealed class GameWindow : IDisposable
EndSize = 0.06f,
};
// ── 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;
private float GetActiveSensitivity()
{
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
if (_cameraController?.IsFlyMode == true) return _sensFly;
return _sensOrbit;
}
/// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
/// </summary>
private void CycleTimeOfDay()
{
// none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null,
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugVm?.AddToast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugVm?.AddToast("Time override cleared");
}
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
private void CycleWeather()
{
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}");
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
_debugCollisionVisible = !_debugCollisionVisible;
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
/// <summary>
/// Yields the registered DebugPanel(s) so F1 can flip their
/// visibility. Returns nothing when devtools are off.
/// </summary>
private IEnumerable<AcDream.UI.Abstractions.IPanel> EnumerateDebugPanel()
{
// The current ImGuiPanelHost only exposes Register/Unregister,
// not enumerate. We track the DebugPanel through the VM presence
// — the panel is registered iff _debugVm is non-null. Look it
// up via the panel ID convention.
// Defer the actual lookup to the panel host once it grows an
// accessor; for now, no-op when devtools are off.
if (_debugPanel is not null) yield return _debugPanel;
}
// Cached panel reference so EnumerateDebugPanel can return it. Set
// in the DevToolsEnabled construction block above; null otherwise.
private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel;
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL