diff --git a/src/AcDream.App/Rendering/DebugOverlay.cs b/src/AcDream.App/Rendering/DebugOverlay.cs
deleted file mode 100644
index db2ba2c..0000000
--- a/src/AcDream.App/Rendering/DebugOverlay.cs
+++ /dev/null
@@ -1,472 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Numerics;
-using AcDream.Core.Chat;
-using AcDream.Core.Combat;
-
-namespace AcDream.App.Rendering;
-
-///
-/// Screen-space debug HUD. Composes panels on top of the 3D scene using a
-/// + . Panels can be
-/// toggled independently (info / stats / controls-help / compass).
-///
-/// The overlay is stateless w.r.t. game state — callers populate a
-/// each frame and pass it to .
-///
-public sealed class DebugOverlay
-{
- private readonly TextRenderer _text;
- private readonly BitmapFont _font;
-
- public bool ShowInfoPanel { get; set; } = true;
- public bool ShowStatsPanel { get; set; } = true;
- public bool ShowHelpPanel { get; set; } = false;
- public bool ShowCompass { get; set; } = true;
- public bool ShowChatPanel { get; set; } = true;
- public bool ShowEventPanel { get; set; } = true;
-
- ///
- /// Live chat log to render in the bottom-left chat panel. Hook this
- /// up to the session's ChatLog so server-sent ChannelBroadcast / Tell /
- /// HearSpeech / system messages appear on screen.
- ///
- public ChatLog? Chat { get; set; }
-
- /// Live combat state for damage floaters + target HP display.
- public CombatState? Combat { get; set; }
-
- // Tail of recent combat events, captured from CombatState callbacks so
- // they stay on-screen long enough to read even when you're moving fast.
- private readonly List<(string text, Vector4 color, float timeLeft)> _eventTail = new();
- private const int MaxEventTail = 8;
- private const float EventHoldSec = 5f;
-
- ///
- /// Bind to CombatState events so incoming damage / evasion events
- /// surface as transient event-log lines. Call once after Combat is set.
- ///
- public void BindCombat(CombatState combat)
- {
- combat.DamageTaken += d => PushEvent(
- $"<< {d.AttackerName} hits you for {d.Damage}{(d.Critical ? " CRIT!" : "")}", Red);
- combat.DamageDealtAccepted += d => PushEvent(
- $">> you hit {d.DefenderName} for {d.Damage}", Yellow);
- combat.EvadedIncoming += a => PushEvent(
- $"<< {a}'s attack misses you", Green);
- combat.MissedOutgoing += a => PushEvent(
- $">> your attack misses {a}", Grey);
- }
-
- private void PushEvent(string text, Vector4 color)
- {
- _eventTail.Add((text, color, EventHoldSec));
- while (_eventTail.Count > MaxEventTail) _eventTail.RemoveAt(0);
- }
-
- // Toast state for transient notifications (e.g. "wireframes off").
- private string? _toastText;
- private Vector4 _toastColor = White;
- private float _toastTimeLeft;
-
- private static readonly Vector4 White = new(1f, 1f, 1f, 1f);
- private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f);
- private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f);
- private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f);
- private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f);
- private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f);
- private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f);
- private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f);
-
- /// Per-frame state snapshot from the caller. See .
- public readonly record struct Snapshot(
- float Fps,
- float FrameTimeMs,
- Vector3 PlayerPos,
- float HeadingDeg,
- uint CellId,
- bool OnGround,
- bool InPlayerMode,
- bool InFlyMode,
- float VerticalVelocity,
- int EntityCount,
- int AnimatedCount,
- int LandblocksVisible,
- int LandblocksTotal,
- int ShadowObjectCount,
- float NearestObjDist,
- string NearestObjLabel,
- bool Colliding,
- bool DebugWireframes,
- int StreamingRadius,
- float MouseSensitivity,
- float ChaseDistance,
- bool RmbOrbit,
- // Phase G.1/G.2 — sky + weather + lighting
- string HourName = "",
- float DayFraction = 0f,
- string Weather = "Clear",
- int ActiveLights = 0,
- int RegisteredLights = 0,
- int ParticleCount = 0);
-
- public DebugOverlay(TextRenderer text, BitmapFont font)
- {
- _text = text;
- _font = font;
- }
-
- /// Show a short message in the center-top for seconds.
- public void Toast(string message, float durationSec = 1.5f, Vector4? color = null)
- {
- _toastText = message;
- _toastColor = color ?? Yellow;
- _toastTimeLeft = durationSec;
- }
-
- /// Advance toast timer. Call once per frame with dt in seconds.
- public void Update(float dt)
- {
- if (_toastTimeLeft > 0f)
- {
- _toastTimeLeft -= dt;
- if (_toastTimeLeft <= 0f)
- _toastText = null;
- }
-
- // Age event-tail entries; drop the ones that expired this frame.
- for (int i = _eventTail.Count - 1; i >= 0; i--)
- {
- var e = _eventTail[i];
- e.timeLeft -= dt;
- if (e.timeLeft <= 0f) _eventTail.RemoveAt(i);
- else _eventTail[i] = e;
- }
- }
-
- public void Draw(Snapshot s, Vector2 screenSize)
- {
- _text.Begin(screenSize);
-
- if (ShowInfoPanel) DrawInfoPanel(s);
- if (ShowStatsPanel) DrawStatsPanel(s, screenSize);
- if (ShowCompass) DrawCompass(s, screenSize);
- if (ShowHelpPanel) DrawHelpPanel(screenSize);
- if (ShowChatPanel) DrawChatPanel(screenSize);
- if (ShowEventPanel) DrawEventPanel(screenSize);
- DrawHintBar(screenSize);
- DrawToast(screenSize);
-
- _text.Flush(_font);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Info panel — top-left: mode, position, heading, ground, nearest-obj.
- // ──────────────────────────────────────────────────────────────────────
-
- private void DrawInfoPanel(Snapshot s)
- {
- var lines = new List<(string text, Vector4 color)>();
-
- string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT ");
- var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey);
- lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}",
- modeColor));
-
- lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White));
- lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White));
-
- var gColor = s.OnGround ? Green : Yellow;
- string vzStr = s.VerticalVelocity >= 0
- ? $"+{s.VerticalVelocity,4:F2}"
- : $"{s.VerticalVelocity,5:F2}";
- lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor));
-
- var nColor = s.Colliding ? Red : White;
- string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m";
- lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor));
-
- var cColor = s.Colliding ? Red : Green;
- lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor));
-
- if (s.InPlayerMode)
- {
- string orbitTag = s.RmbOrbit ? " [RMB orbit]" : "";
- lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}",
- s.RmbOrbit ? Cyan : White));
- }
- lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey));
-
- DrawPanel(10f, 10f, lines);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Stats panel — top-right: fps, frame time, landblock/entity counters.
- // ──────────────────────────────────────────────────────────────────────
-
- private void DrawStatsPanel(Snapshot s, Vector2 screenSize)
- {
- var lines = new List<(string text, Vector4 color)>
- {
- ($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green),
- ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
- ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
- ($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
- // Phase G: sky + weather + dynamic lighting surface.
- ($"time {s.DayFraction,5:F2} {s.HourName}", Cyan),
- ($"wx {s.Weather,-8} parts {s.ParticleCount,5}", Cyan),
- ($"lit {s.ActiveLights}/{s.RegisteredLights} ", Cyan),
- };
-
- float pad = 10f;
- float panelW = MeasureMax(lines) + 2 * InnerPad;
- DrawPanel(screenSize.X - panelW - pad, pad, lines, panelW);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Compass — bottom-center: arrow indicating heading, cardinal labels.
- // ──────────────────────────────────────────────────────────────────────
-
- private void DrawCompass(Snapshot s, Vector2 screenSize)
- {
- // Simple linear compass strip across 180° of horizon at the top-center.
- const float stripW = 360f;
- const float stripH = 20f;
- float cx = screenSize.X * 0.5f;
- float top = 8f;
- float left = cx - stripW * 0.5f;
-
- _text.DrawRect(left, top, stripW, stripH, PanelBg);
- _text.DrawRectOutline(left, top, stripW, stripH, PanelBorder);
-
- // Mark every 30° of yaw, labelled with cardinal letters at N/E/S/W.
- // Heading 0 = +X (east). We show 180° wide, ±90° from current heading.
- float h = NormalizeDeg(s.HeadingDeg);
- for (int d = 0; d < 360; d += 15)
- {
- float rel = NormalizeDegSigned(d - h);
- if (rel < -90 || rel > 90) continue;
- float x = cx + rel / 90f * (stripW * 0.5f - 6f);
- bool bold = (d % 90) == 0;
- float tickH = bold ? stripH * 0.9f : stripH * 0.4f;
- _text.DrawRect(x - 0.5f, top + stripH - tickH, 1f, tickH, bold ? White : Grey);
-
- if (bold)
- {
- string lab = d switch
- {
- 0 => "E",
- 90 => "N",
- 180 => "W",
- 270 => "S",
- _ => ""
- };
- if (lab.Length > 0)
- {
- float w = _font.MeasureWidth(lab);
- _text.DrawString(_font, lab, x - w * 0.5f, top - _font.LineHeight + 4f, White);
- }
- }
- }
-
- // Current heading indicator arrow below the strip.
- _text.DrawRect(cx - 1.5f, top + stripH + 2f, 3f, 6f, Yellow);
- string hText = $"{h,3:F0}°";
- float hw = _font.MeasureWidth(hText);
- _text.DrawString(_font, hText, cx - hw * 0.5f, top + stripH + 10f, Yellow);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Help panel — center: full keybind cheat-sheet, shown when F1 is pressed.
- // ──────────────────────────────────────────────────────────────────────
-
- private static readonly (string key, string desc)[] Keybinds =
- {
- ("F1", "toggle this help"),
- ("F2", "toggle collision wireframes"),
- ("F3", "console dump (pos + nearby objects)"),
- ("F4", "toggle debug HUD info panel"),
- ("F5", "toggle stats panel"),
- ("F6", "toggle compass"),
- ("F7", "cycle time-of-day override (none/midnight/dawn/noon/dusk)"),
- ("F10", "cycle weather (clear/overcast/rain/snow/storm)"),
- ("F", "toggle fly camera"),
- ("Tab", "toggle player mode (requires login)"),
- ("W A S D", "move (player mode) / fly"),
- ("Mouse", "turn character / look (fly)"),
- ("Hold RMB", "free orbit camera around player"),
- ("Wheel", "zoom chase camera in / out"),
- ("F8 / F9", "mouse sensitivity slower / faster"),
- ("Space", "jump (hold to charge)"),
- ("Shift", "run"),
- ("Escape", "exit fly / player / close window"),
- };
-
- private void DrawHelpPanel(Vector2 screenSize)
- {
- var lines = new List<(string text, Vector4 color)>();
- lines.Add(("CONTROLS", Yellow));
- lines.Add(("", White));
- foreach (var (k, d) in Keybinds)
- lines.Add(($" {k,-9} {d}", White));
- lines.Add(("", White));
- lines.Add(("Press F1 to close", Grey));
-
- float panelW = MeasureMax(lines) + 2 * InnerPad;
- float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
- float x = (screenSize.X - panelW) * 0.5f;
- float y = (screenSize.Y - panelH) * 0.5f;
- DrawPanel(x, y, lines, panelW);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Chat panel — bottom-left: live ChatLog tail (Phase H.1).
- // Proves the F.1 GameEvent dispatcher → ChatLog pipeline is alive.
- // ──────────────────────────────────────────────────────────────────────
-
- private void DrawChatPanel(Vector2 screenSize)
- {
- if (Chat is null) return;
- const int TailSize = 10;
- var snap = Chat.Snapshot();
- if (snap.Length == 0) return;
-
- var lines = new List<(string text, Vector4 color)>();
- int start = Math.Max(0, snap.Length - TailSize);
- for (int i = start; i < snap.Length; i++)
- {
- var e = snap[i];
- string prefix = e.Kind switch
- {
- ChatKind.LocalSpeech => $"[say] {e.Sender}: ",
- ChatKind.RangedSpeech => $"[shout] {e.Sender}: ",
- ChatKind.Channel => $"[ch{e.ChannelId}] {e.Sender}: ",
- ChatKind.Tell => $"[tell] {e.Sender}: ",
- ChatKind.System => "* ",
- ChatKind.Popup => "!! ",
- _ => "",
- };
- Vector4 color = e.Kind switch
- {
- ChatKind.Tell => Cyan,
- ChatKind.Channel => Green,
- ChatKind.System => Yellow,
- ChatKind.Popup => Red,
- _ => White,
- };
- lines.Add(($"{prefix}{e.Text}", color));
- }
-
- // Render from bottom-up above the hint bar.
- float panelW = Math.Max(520f, MeasureMax(lines) + 2 * InnerPad);
- float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
- float x = 10f;
- float y = screenSize.Y - _font.LineHeight - 18f - panelH;
- DrawPanel(x, y, lines, panelW);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Event panel — bottom-right: combat damage + evasion log (Phase E.4).
- // Each entry fades as its timer runs out.
- // ──────────────────────────────────────────────────────────────────────
-
- private void DrawEventPanel(Vector2 screenSize)
- {
- if (_eventTail.Count == 0) return;
-
- float lineH = _font.LineHeight;
- float panelW = 360f;
- // Prepare fade-out per line.
- float x = screenSize.X - panelW - 10f;
- float panelH = _eventTail.Count * lineH + 2 * InnerPad;
- float y = screenSize.Y - _font.LineHeight - 18f - panelH;
-
- _text.DrawRect(x, y, panelW, panelH, PanelBg);
- _text.DrawRectOutline(x, y, panelW, panelH, PanelBorder);
-
- float cy = y + InnerPad;
- for (int i = 0; i < _eventTail.Count; i++)
- {
- var e = _eventTail[i];
- var c = e.color;
- c.W *= MathF.Min(1f, e.timeLeft / 1.5f);
- _text.DrawString(_font, e.text, x + InnerPad, cy, c);
- cy += lineH;
- }
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Hint bar — bottom-left: always-visible "F1 for help" reminder.
- // ──────────────────────────────────────────────────────────────────────
-
- private void DrawHintBar(Vector2 screenSize)
- {
- string hint = "F1 help F2 wires F3 dump F4/F5/F6 panels F7 time F8/F9 sens F10 weather Tab player RMB orbit Wheel zoom";
- float w = _font.MeasureWidth(hint);
- float pad = 10f;
- float y = screenSize.Y - _font.LineHeight - pad;
- _text.DrawRect(pad - 4, y - 3, w + 8, _font.LineHeight + 6, PanelBg);
- _text.DrawString(_font, hint, pad, y, Grey);
- }
-
- private void DrawToast(Vector2 screenSize)
- {
- if (_toastText is null || _toastTimeLeft <= 0f) return;
- float w = _font.MeasureWidth(_toastText);
- float x = (screenSize.X - w) * 0.5f;
- float y = 60f;
- var c = _toastColor;
- float alpha = MathF.Min(1f, _toastTimeLeft / 0.5f);
- c.W *= alpha;
- var bg = PanelBg;
- bg.W *= alpha;
- _text.DrawRect(x - 10, y - 4, w + 20, _font.LineHeight + 8, bg);
- _text.DrawString(_font, _toastText, x, y, c);
- }
-
- // ──────────────────────────────────────────────────────────────────────
- // Panel helpers.
- // ──────────────────────────────────────────────────────────────────────
-
- private const float InnerPad = 8f;
-
- private float MeasureMax(IReadOnlyList<(string text, Vector4 color)> lines)
- {
- float m = 0;
- foreach (var (text, _) in lines)
- m = MathF.Max(m, _font.MeasureWidth(text));
- return m;
- }
-
- private void DrawPanel(float x, float y, IReadOnlyList<(string text, Vector4 color)> lines, float? widthOverride = null)
- {
- float maxW = MeasureMax(lines);
- float panelW = widthOverride ?? (maxW + 2 * InnerPad);
- float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
-
- _text.DrawRect(x, y, panelW, panelH, PanelBg);
- _text.DrawRectOutline(x, y, panelW, panelH, PanelBorder);
-
- float cy = y + InnerPad;
- foreach (var (text, color) in lines)
- {
- _text.DrawString(_font, text, x + InnerPad, cy, color);
- cy += _font.LineHeight;
- }
- }
-
- private static float NormalizeDeg(float deg)
- {
- deg %= 360f;
- if (deg < 0) deg += 360f;
- return deg;
- }
-
- private static float NormalizeDegSigned(float deg)
- {
- deg %= 360f;
- if (deg > 180) deg -= 360f;
- if (deg < -180) deg += 360f;
- return deg;
- }
-}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ac201d6..d5428eb 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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.
+
+ /// Player-mode-aware position source for the DebugPanel.
+ 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;
+ }
+
+ /// Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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");
+ }
+ }
+
+ ///
+ /// Cycle the weather kind. Same body as the old F10 keybind handler.
+ ///
+ 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]}");
+ }
+
+ ///
+ /// Toggle the collision-wires debug renderer. Same body as the old
+ /// F2 keybind handler.
+ ///
+ private void ToggleCollisionWires()
+ {
+ _debugCollisionVisible = !_debugCollisionVisible;
+ _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
+ }
+
+ ///
+ /// Yields the registered DebugPanel(s) so F1 can flip their
+ /// visibility. Returns nothing when devtools are off.
+ ///
+ private IEnumerable 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
diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
new file mode 100644
index 0000000..076faa2
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
@@ -0,0 +1,250 @@
+using System.Numerics;
+
+namespace AcDream.UI.Abstractions.Panels.Debug;
+
+///
+/// The Phase I.2 debug panel — single ImGui window with collapsing-header
+/// sections that replace the old custom DebugOverlay's six floating
+/// panels (Info / Stats / Help / Compass / Chat / Event) plus the toast
+/// surface. Reads through so values are always live.
+///
+///
+/// Layout: Player Info, Performance, Compass, Help, Combat events, Recent
+/// toasts, Diagnostics. Each section is a CollapsingHeader;
+/// importance-ranked sections default open, niche ones default closed.
+///
+///
+///
+/// Reuses the I.1 widget extensions only; never imports a backend
+/// namespace. Same constraints as VitalsPanel and ChatPanel.
+///
+///
+public sealed class DebugPanel : IPanel
+{
+ private readonly DebugVM _vm;
+
+ public DebugPanel(DebugVM vm)
+ {
+ _vm = vm ?? throw new ArgumentNullException(nameof(vm));
+ }
+
+ ///
+ public string Id => "acdream.debug";
+
+ ///
+ public string Title => "Debug";
+
+ ///
+ public bool IsVisible { get; set; } = true;
+
+ ///
+ /// Cheat-sheet of currently meaningful keybinds. Kept as a static
+ /// table because the data is stable and the panel only renders
+ /// labels — no behavior change to the bindings themselves.
+ ///
+ private static readonly (string Key, string Action)[] Keybinds =
+ {
+ ("Esc", "exit fly / player / close window"),
+ ("Tab", "toggle player mode (when in-world)"),
+ ("F", "toggle fly camera"),
+ ("F1", "toggle this debug panel"),
+ ("F2", "toggle collision wireframes"),
+ ("F3", "console dump (pos + nearby objects)"),
+ ("F7", "cycle time-of-day override"),
+ ("F8 / F9", "mouse sensitivity slower / faster"),
+ ("F10", "cycle weather"),
+ ("W A S D", "move (player mode) / fly"),
+ ("Mouse", "turn character / look (fly)"),
+ ("Hold RMB", "free orbit camera around player"),
+ ("Wheel", "zoom chase camera in / out"),
+ ("Space", "jump"),
+ ("Shift", "run"),
+ };
+
+ ///
+ public void Render(PanelContext ctx, IPanelRenderer renderer)
+ {
+ if (!renderer.Begin(Title))
+ {
+ renderer.End();
+ return;
+ }
+
+ DrawPlayerInfo(renderer);
+ DrawPerformance(renderer);
+ DrawCompass(renderer);
+ DrawHelp(renderer);
+ DrawCombatEvents(renderer);
+ DrawRecentToasts(renderer);
+ DrawDiagnostics(renderer);
+
+ renderer.End();
+ }
+
+ // ── Sections ──────────────────────────────────────────────────────
+
+ private void DrawPlayerInfo(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Player Info", defaultOpen: true)) return;
+
+ string mode = _vm.InPlayerMode ? "PLAYER"
+ : _vm.InFlyMode ? "FLY"
+ : "ORBIT";
+ r.Text($"mode: {mode} cell: 0x{_vm.CellId:X8}");
+ var p = _vm.PlayerPosition;
+ r.Text($"pos: ({p.X,7:F1}, {p.Y,7:F1}, {p.Z,7:F2})");
+ r.Text($"heading: {_vm.HeadingDeg,3:F0}°");
+ r.Text($"grounded: {(_vm.OnGround ? "yes" : "no ")} vZ: {_vm.VerticalVelocity,5:F2}");
+
+ string near = float.IsPositiveInfinity(_vm.NearestObjDist)
+ ? "---"
+ : $"{_vm.NearestObjDist,4:F1}m";
+ if (_vm.Colliding)
+ {
+ r.TextColored(new Vector4(1f, 0.4f, 0.35f, 1f),
+ $"near: {near} {_vm.NearestObjLabel} [BLOCKED]");
+ }
+ else
+ {
+ r.Text($"near: {near} {_vm.NearestObjLabel}");
+ }
+
+ if (_vm.InPlayerMode)
+ r.Text($"chase dist: {_vm.ChaseDistance,4:F1}m{(_vm.RmbOrbit ? " [RMB orbit]" : "")}");
+ r.Text($"sens: {_vm.MouseSensitivity:F3}x");
+ }
+
+ private void DrawPerformance(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Performance", defaultOpen: true)) return;
+
+ r.Text($"fps: {_vm.Fps,5:F0} frame: {_vm.FrameMs,5:F1} ms");
+ r.Text($"visible LB: {_vm.LandblocksVisible,3}/{_vm.LandblocksTotal,3} radius: {_vm.StreamingRadius}");
+ r.Text($"entities: {_vm.EntityCount,4} animated: {_vm.AnimatedCount,3} coll: {_vm.ShadowObjectCount}");
+ r.Text($"lights: {_vm.ActiveLights}/{_vm.RegisteredLights} particles: {_vm.ParticleCount}");
+ r.Text($"time: {_vm.DayFraction,5:F2} {_vm.HourName} weather: {_vm.Weather}");
+ }
+
+ private void DrawCompass(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Compass", defaultOpen: false)) return;
+
+ // Phase I.2 stub — the visual strip + cardinal markers from the
+ // old DebugOverlay relied on raw 2D-rect primitives we don't (and
+ // shouldn't) expose through IPanelRenderer. The fancy compass
+ // strip lands in D.6 with proper world-HUD draw-list primitives.
+ // For now show heading degrees + compass cardinal label.
+ float h = NormalizeDeg(_vm.HeadingDeg);
+ r.Text($"heading: {h,3:F0}° cardinal: {Cardinal(h)}");
+ }
+
+ private void DrawHelp(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Help", defaultOpen: false)) return;
+
+ r.BeginTable("debug.help", 2);
+ foreach (var (key, action) in Keybinds)
+ {
+ r.TableNextColumn();
+ r.Text(key);
+ r.TableNextColumn();
+ r.Text(action);
+ }
+ r.EndTable();
+ }
+
+ private void DrawCombatEvents(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Combat events", defaultOpen: true)) return;
+
+ if (_vm.CombatEvents.Count == 0)
+ {
+ r.Text("(no recent combat)");
+ return;
+ }
+
+ foreach (var line in _vm.CombatEvents)
+ {
+ r.TextColored(ColorForCombat(line.Kind), line.Text);
+ }
+ }
+
+ private void DrawRecentToasts(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Recent toasts", defaultOpen: false)) return;
+
+ if (_vm.RecentToasts.Count == 0)
+ {
+ r.Text("(none)");
+ return;
+ }
+
+ foreach (var t in _vm.RecentToasts)
+ {
+ string ts = t.Timestamp.ToLocalTime().ToString("HH:mm:ss");
+ r.TextColored(ColorForToast(t.Kind), $"[{ts}] {t.Text}");
+ }
+ }
+
+ private void DrawDiagnostics(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Diagnostics", defaultOpen: true)) return;
+
+ bool dumpMotion = _vm.DumpMotion;
+ bool dumpVitals = _vm.DumpVitals;
+ bool dumpOpcodes = _vm.DumpOpcodes;
+ bool dumpSky = _vm.DumpSky;
+
+ if (r.Checkbox("Dump motion (ACDREAM_DUMP_MOTION)", ref dumpMotion)) _vm.DumpMotion = dumpMotion;
+ if (r.Checkbox("Dump vitals (ACDREAM_DUMP_VITALS)", ref dumpVitals)) _vm.DumpVitals = dumpVitals;
+ if (r.Checkbox("Dump opcodes (ACDREAM_DUMP_OPCODES)", ref dumpOpcodes)) _vm.DumpOpcodes = dumpOpcodes;
+ if (r.Checkbox("Dump sky (ACDREAM_DUMP_SKY)", ref dumpSky)) _vm.DumpSky = dumpSky;
+
+ r.Spacing();
+
+ // Cycle / toggle actions live on the VM as Action handles; the
+ // host (GameWindow) populates them with the same lambdas the
+ // old F7/F10/F2 keybinds used.
+ if (r.Button("Cycle time of day")) _vm.CycleTimeOfDay?.Invoke();
+ r.SameLine();
+ if (r.Button("Cycle weather")) _vm.CycleWeather?.Invoke();
+ r.SameLine();
+ if (r.Button("Toggle collision wires")) _vm.ToggleCollisionWires?.Invoke();
+
+ r.Text(_vm.DebugWireframes ? "collision wires: ON" : "collision wires: OFF");
+ }
+
+ // ── Color helpers ─────────────────────────────────────────────────
+
+ private static Vector4 ColorForCombat(CombatEventKind kind) => kind switch
+ {
+ CombatEventKind.Info => new Vector4(1.0f, 0.9f, 0.3f, 1f), // yellow
+ CombatEventKind.Warn => new Vector4(1.0f, 0.5f, 0.5f, 1f), // light red
+ CombatEventKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1f), // deep red
+ _ => new Vector4(1f, 1f, 1f, 1f),
+ };
+
+ private static Vector4 ColorForToast(ToastKind kind) => kind switch
+ {
+ ToastKind.Warn => new Vector4(1.0f, 0.8f, 0.4f, 1f),
+ ToastKind.Error => new Vector4(1.0f, 0.4f, 0.4f, 1f),
+ _ => new Vector4(0.85f, 0.95f, 1.0f, 1f),
+ };
+
+ private static float NormalizeDeg(float deg)
+ {
+ deg %= 360f;
+ if (deg < 0) deg += 360f;
+ return deg;
+ }
+
+ private static string Cardinal(float deg)
+ {
+ // Heading 0 = +X (east) per the old overlay. Same eight cardinal
+ // labels — N/E/S/W with NE/SE/SW/NW between.
+ // 0=E, 90=N, 180=W, 270=S (acdream's coordinate convention).
+ string[] dirs = { "E", "NE", "N", "NW", "W", "SW", "S", "SE" };
+ int idx = (int)MathF.Round(deg / 45f) & 7;
+ return dirs[idx];
+ }
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
new file mode 100644
index 0000000..7765f95
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
@@ -0,0 +1,289 @@
+using System.Numerics;
+using AcDream.Core.Combat;
+
+namespace AcDream.UI.Abstractions.Panels.Debug;
+
+///
+/// Severity tag for a single combat-event line in the
+/// ring. The panel reads this to pick
+/// a TextColored rgba per row (yellow info / red warn / deep-red
+/// error). Mirrors the same tri-tone the chat panel uses for combat
+/// (Phase I.7).
+///
+public enum CombatEventKind
+{
+ /// You dealt damage / landed a hit. Yellow.
+ Info,
+ /// An incoming hit you evaded. Red.
+ Warn,
+ /// You took damage. Deep red.
+ Error,
+}
+
+///
+/// Single typed entry in the combat-events ring.
+/// is captured at append time so a future panel revision can fade old
+/// entries; for I.2 the panel just renders the text + rgba.
+///
+public readonly record struct CombatEventLine(
+ DateTime Timestamp,
+ CombatEventKind Kind,
+ string Text);
+
+///
+/// Severity tag for a transient toast message. Mirrors
+/// but lives in its own enum so the toast
+/// surface can grow (e.g. an "OK" green) without dragging the combat
+/// surface along.
+///
+public enum ToastKind
+{
+ Info,
+ Warn,
+ Error,
+}
+
+/// Single transient toast message kept in the recent-toasts ring.
+public readonly record struct ToastMessage(
+ DateTime Timestamp,
+ ToastKind Kind,
+ string Text);
+
+///
+/// ViewModel for the Phase I.2 . Read-through
+/// (no caching): every property forwards to a Func<T> that
+/// the host (GameWindow) wires up at construction. Internal
+/// state is limited to (a) the combat-event ring buffer, populated via a
+/// self-subscription to 's typed events
+/// (replacing the old DebugOverlay.BindCombat); (b) the toast
+/// ring; (c) the diagnostic-flag bools the panel exposes as checkboxes.
+///
+///
+/// Constructor explosion is intentional and acceptable here — the VM
+/// lives entirely inside the AcDream.App composition root, not in any
+/// plugin-facing surface. A nicer abstraction can come later if more
+/// debug panels appear.
+///
+///
+public sealed class DebugVM
+{
+ /// Maximum number of combat-event lines kept in the ring.
+ public const int MaxCombatEvents = 25;
+
+ /// Maximum number of recent toast messages kept in the ring.
+ public const int MaxRecentToasts = 25;
+
+ private readonly Func _getPlayerPosition;
+ private readonly Func _getPlayerHeadingDeg;
+ private readonly Func _getPlayerCellId;
+ private readonly Func _getPlayerOnGround;
+ private readonly Func _getInPlayerMode;
+ private readonly Func _getInFlyMode;
+ private readonly Func _getVerticalVelocity;
+ private readonly Func _getEntityCount;
+ private readonly Func _getAnimatedCount;
+ private readonly Func _getLandblocksVisible;
+ private readonly Func _getLandblocksTotal;
+ private readonly Func _getShadowObjectCount;
+ private readonly Func _getNearestObjDist;
+ private readonly Func _getNearestObjLabel;
+ private readonly Func _getColliding;
+ private readonly Func _getDebugWireframes;
+ private readonly Func _getStreamingRadius;
+ private readonly Func _getMouseSensitivity;
+ private readonly Func _getChaseDistance;
+ private readonly Func _getRmbOrbit;
+ private readonly Func _getHourName;
+ private readonly Func _getDayFraction;
+ private readonly Func _getWeather;
+ private readonly Func _getActiveLights;
+ private readonly Func _getRegisteredLights;
+ private readonly Func _getParticleCount;
+ private readonly Func _getFps;
+ private readonly Func _getFrameMs;
+
+ private readonly Queue _combatEvents = new();
+ private readonly Queue _toasts = new();
+
+ ///
+ /// Build a VM bound to live data sources. Every Func is read
+ /// per-frame by the panel — pass closures that resolve to the
+ /// authoritative source on each call so the panel always sees fresh
+ /// state.
+ ///
+ public DebugVM(
+ Func getPlayerPosition,
+ Func getPlayerHeadingDeg,
+ Func getPlayerCellId,
+ Func getPlayerOnGround,
+ Func getInPlayerMode,
+ Func getInFlyMode,
+ Func getVerticalVelocity,
+ Func getEntityCount,
+ Func getAnimatedCount,
+ Func getLandblocksVisible,
+ Func getLandblocksTotal,
+ Func getShadowObjectCount,
+ Func getNearestObjDist,
+ Func getNearestObjLabel,
+ Func getColliding,
+ Func getDebugWireframes,
+ Func getStreamingRadius,
+ Func getMouseSensitivity,
+ Func getChaseDistance,
+ Func getRmbOrbit,
+ Func getHourName,
+ Func getDayFraction,
+ Func getWeather,
+ Func getActiveLights,
+ Func getRegisteredLights,
+ Func getParticleCount,
+ Func getFps,
+ Func getFrameMs,
+ CombatState combat)
+ {
+ if (combat is null) 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));
+ _getPlayerOnGround = getPlayerOnGround ?? throw new ArgumentNullException(nameof(getPlayerOnGround));
+ _getInPlayerMode = getInPlayerMode ?? throw new ArgumentNullException(nameof(getInPlayerMode));
+ _getInFlyMode = getInFlyMode ?? throw new ArgumentNullException(nameof(getInFlyMode));
+ _getVerticalVelocity = getVerticalVelocity ?? throw new ArgumentNullException(nameof(getVerticalVelocity));
+ _getEntityCount = getEntityCount ?? throw new ArgumentNullException(nameof(getEntityCount));
+ _getAnimatedCount = getAnimatedCount ?? throw new ArgumentNullException(nameof(getAnimatedCount));
+ _getLandblocksVisible = getLandblocksVisible ?? throw new ArgumentNullException(nameof(getLandblocksVisible));
+ _getLandblocksTotal = getLandblocksTotal ?? throw new ArgumentNullException(nameof(getLandblocksTotal));
+ _getShadowObjectCount = getShadowObjectCount ?? throw new ArgumentNullException(nameof(getShadowObjectCount));
+ _getNearestObjDist = getNearestObjDist ?? throw new ArgumentNullException(nameof(getNearestObjDist));
+ _getNearestObjLabel = getNearestObjLabel ?? throw new ArgumentNullException(nameof(getNearestObjLabel));
+ _getColliding = getColliding ?? throw new ArgumentNullException(nameof(getColliding));
+ _getDebugWireframes = getDebugWireframes ?? throw new ArgumentNullException(nameof(getDebugWireframes));
+ _getStreamingRadius = getStreamingRadius ?? throw new ArgumentNullException(nameof(getStreamingRadius));
+ _getMouseSensitivity = getMouseSensitivity ?? throw new ArgumentNullException(nameof(getMouseSensitivity));
+ _getChaseDistance = getChaseDistance ?? throw new ArgumentNullException(nameof(getChaseDistance));
+ _getRmbOrbit = getRmbOrbit ?? throw new ArgumentNullException(nameof(getRmbOrbit));
+ _getHourName = getHourName ?? throw new ArgumentNullException(nameof(getHourName));
+ _getDayFraction = getDayFraction ?? throw new ArgumentNullException(nameof(getDayFraction));
+ _getWeather = getWeather ?? throw new ArgumentNullException(nameof(getWeather));
+ _getActiveLights = getActiveLights ?? throw new ArgumentNullException(nameof(getActiveLights));
+ _getRegisteredLights = getRegisteredLights ?? throw new ArgumentNullException(nameof(getRegisteredLights));
+ _getParticleCount = getParticleCount ?? throw new ArgumentNullException(nameof(getParticleCount));
+ _getFps = getFps ?? throw new ArgumentNullException(nameof(getFps));
+ _getFrameMs = getFrameMs ?? throw new ArgumentNullException(nameof(getFrameMs));
+
+ // 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}");
+ }
+
+ // ── Read-through value surfaces ───────────────────────────────────
+
+ public Vector3 PlayerPosition => _getPlayerPosition();
+ public float HeadingDeg => _getPlayerHeadingDeg();
+ public uint CellId => _getPlayerCellId();
+ public bool OnGround => _getPlayerOnGround();
+ public bool InPlayerMode => _getInPlayerMode();
+ public bool InFlyMode => _getInFlyMode();
+ public float VerticalVelocity => _getVerticalVelocity();
+ public int EntityCount => _getEntityCount();
+ public int AnimatedCount => _getAnimatedCount();
+ public int LandblocksVisible => _getLandblocksVisible();
+ public int LandblocksTotal => _getLandblocksTotal();
+ public int ShadowObjectCount => _getShadowObjectCount();
+ public float NearestObjDist => _getNearestObjDist();
+ public string NearestObjLabel => _getNearestObjLabel();
+ public bool Colliding => _getColliding();
+ public bool DebugWireframes => _getDebugWireframes();
+ public int StreamingRadius => _getStreamingRadius();
+ public float MouseSensitivity => _getMouseSensitivity();
+ public float ChaseDistance => _getChaseDistance();
+ public bool RmbOrbit => _getRmbOrbit();
+ public string HourName => _getHourName();
+ public float DayFraction => _getDayFraction();
+ public string Weather => _getWeather();
+ public int ActiveLights => _getActiveLights();
+ public int RegisteredLights => _getRegisteredLights();
+ public int ParticleCount => _getParticleCount();
+ public float Fps => _getFps();
+ public float FrameMs => _getFrameMs();
+
+ // ── Diagnostic toggles (env-var-style runtime flags) ───────────────
+
+ /// Mirror of ACDREAM_DUMP_MOTION; flipped at runtime via the panel.
+ public bool DumpMotion { get; set; }
+ /// Mirror of ACDREAM_DUMP_VITALS.
+ public bool DumpVitals { get; set; }
+ /// Mirror of ACDREAM_DUMP_OPCODES.
+ public bool DumpOpcodes { get; set; }
+ /// Mirror of ACDREAM_DUMP_SKY.
+ public bool DumpSky { get; set; }
+
+ // ── Action hooks invoked by panel buttons ──────────────────────────
+
+ ///
+ /// Cycle the time-of-day debug override (matches the old F7
+ /// behavior — none → midnight → dawn → noon → dusk → none). Wired
+ /// by GameWindow; null when no host is available (tests).
+ ///
+ public Action? CycleTimeOfDay { get; set; }
+
+ ///
+ /// Cycle the weather-kind debug override (matches the old F10
+ /// behavior — clear → overcast → rain → snow → storm).
+ ///
+ public Action? CycleWeather { get; set; }
+
+ ///
+ /// Toggle the collision-wires debug renderer. Same effect as the
+ /// old F2 keybind, which we keep as a hotkey alias.
+ ///
+ public Action? ToggleCollisionWires { get; set; }
+
+ // ── Combat event ring + toast ring ─────────────────────────────────
+
+ ///
+ /// Snapshot view of the combat-event ring. Oldest-first; the panel
+ /// can iterate and render each line through TextColored
+ /// based on .
+ ///
+ public IReadOnlyCollection CombatEvents => _combatEvents;
+
+ /// Snapshot view of the recent-toasts ring (oldest-first).
+ public IReadOnlyCollection RecentToasts => _toasts;
+
+ ///
+ /// Append a toast message to the ring. Cap at
+ /// ; oldest entries drop. The panel's
+ /// "Recent toasts" section reads this; no on-screen flash for I.2.
+ ///
+ public void AddToast(string text, ToastKind kind = ToastKind.Info)
+ {
+ if (string.IsNullOrEmpty(text)) return;
+ _toasts.Enqueue(new ToastMessage(DateTime.UtcNow, kind, text));
+ while (_toasts.Count > MaxRecentToasts)
+ _toasts.Dequeue();
+ }
+
+ private void Push(CombatEventKind kind, string text)
+ {
+ _combatEvents.Enqueue(new CombatEventLine(DateTime.UtcNow, kind, text));
+ while (_combatEvents.Count > MaxCombatEvents)
+ _combatEvents.Dequeue();
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
new file mode 100644
index 0000000..33b0fde
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
@@ -0,0 +1,288 @@
+using System.Numerics;
+using AcDream.Core.Combat;
+using AcDream.UI.Abstractions.Panels.Debug;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
+
+///
+/// Tests for the Phase I.2 — read-through ViewModel
+/// for the migrated debug panel. Verifies the combat-event subscription
+/// (replacing the old DebugOverlay.BindCombat), the toast ring
+/// cap, and that the diagnostic-flag bools round-trip without affecting
+/// the rest of the VM.
+///
+public sealed class DebugVMTests
+{
+ ///
+ /// Build a minimal with safe defaults for every
+ /// constructor source. Tests that don't care about a particular source
+ /// just leave it stubbed out.
+ ///
+ private static DebugVM NewVm(CombatState? combat = null)
+ {
+ combat ??= new CombatState();
+ return new DebugVM(
+ getPlayerPosition: () => Vector3.Zero,
+ getPlayerHeadingDeg: () => 0f,
+ getPlayerCellId: () => 0u,
+ getPlayerOnGround: () => true,
+ getInPlayerMode: () => false,
+ getInFlyMode: () => false,
+ getVerticalVelocity: () => 0f,
+ getEntityCount: () => 0,
+ getAnimatedCount: () => 0,
+ getLandblocksVisible: () => 0,
+ getLandblocksTotal: () => 0,
+ getShadowObjectCount: () => 0,
+ getNearestObjDist: () => float.PositiveInfinity,
+ getNearestObjLabel: () => "-",
+ getColliding: () => false,
+ getDebugWireframes: () => false,
+ getStreamingRadius: () => 2,
+ getMouseSensitivity: () => 1f,
+ getChaseDistance: () => 0f,
+ getRmbOrbit: () => false,
+ getHourName: () => "Dawnsong",
+ getDayFraction: () => 0.25f,
+ getWeather: () => "Clear",
+ getActiveLights: () => 0,
+ getRegisteredLights: () => 0,
+ getParticleCount: () => 0,
+ getFps: () => 60f,
+ getFrameMs: () => 16.7f,
+ combat: combat);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullCombat()
+ {
+ Assert.Throws(() => new DebugVM(
+ getPlayerPosition: () => Vector3.Zero,
+ getPlayerHeadingDeg: () => 0f,
+ getPlayerCellId: () => 0u,
+ getPlayerOnGround: () => true,
+ getInPlayerMode: () => false,
+ getInFlyMode: () => false,
+ getVerticalVelocity: () => 0f,
+ getEntityCount: () => 0,
+ getAnimatedCount: () => 0,
+ getLandblocksVisible: () => 0,
+ getLandblocksTotal: () => 0,
+ getShadowObjectCount: () => 0,
+ getNearestObjDist: () => 0f,
+ getNearestObjLabel: () => "-",
+ getColliding: () => false,
+ getDebugWireframes: () => false,
+ getStreamingRadius: () => 0,
+ getMouseSensitivity: () => 1f,
+ getChaseDistance: () => 0f,
+ getRmbOrbit: () => false,
+ getHourName: () => "",
+ getDayFraction: () => 0f,
+ getWeather: () => "",
+ getActiveLights: () => 0,
+ getRegisteredLights: () => 0,
+ getParticleCount: () => 0,
+ getFps: () => 0f,
+ getFrameMs: () => 0f,
+ combat: null!));
+ }
+
+ [Fact]
+ public void ReadThrough_PullsLiveValuesPerAccess_NoCache()
+ {
+ // The VM does NOT cache; every property read goes through the
+ // backing Func. Mutating an external box between two reads
+ // must surface immediately.
+ int counter = 0;
+ var vm = new DebugVM(
+ getPlayerPosition: () => Vector3.Zero,
+ getPlayerHeadingDeg: () => 0f,
+ getPlayerCellId: () => 0u,
+ getPlayerOnGround: () => true,
+ getInPlayerMode: () => false,
+ getInFlyMode: () => false,
+ getVerticalVelocity: () => 0f,
+ getEntityCount: () => ++counter,
+ getAnimatedCount: () => 0,
+ getLandblocksVisible: () => 0,
+ getLandblocksTotal: () => 0,
+ getShadowObjectCount: () => 0,
+ getNearestObjDist: () => 0f,
+ getNearestObjLabel: () => "-",
+ getColliding: () => false,
+ getDebugWireframes: () => false,
+ getStreamingRadius: () => 0,
+ getMouseSensitivity: () => 1f,
+ getChaseDistance: () => 0f,
+ getRmbOrbit: () => false,
+ getHourName: () => "",
+ getDayFraction: () => 0f,
+ getWeather: () => "",
+ getActiveLights: () => 0,
+ getRegisteredLights: () => 0,
+ getParticleCount: () => 0,
+ getFps: () => 0f,
+ getFrameMs: () => 0f,
+ combat: new CombatState());
+
+ Assert.Equal(1, vm.EntityCount);
+ Assert.Equal(2, vm.EntityCount);
+ Assert.Equal(3, vm.EntityCount);
+ }
+
+ [Fact]
+ public void DamageTaken_AppendsErrorEvent()
+ {
+ var combat = new CombatState();
+ var vm = NewVm(combat);
+
+ combat.OnVictimNotification(
+ attackerName: "Drudge", attackerGuid: 0x10u,
+ damageType: 0u, damage: 12u, hitQuadrant: 0u,
+ critical: 0u, attackType: 0u);
+
+ var events = vm.CombatEvents.ToList();
+ Assert.Single(events);
+ Assert.Equal(CombatEventKind.Error, events[0].Kind);
+ Assert.Contains("Drudge", events[0].Text);
+ Assert.Contains("12", events[0].Text);
+ }
+
+ [Fact]
+ public void DamageDealt_AppendsInfoEvent()
+ {
+ var combat = new CombatState();
+ var vm = NewVm(combat);
+
+ combat.OnAttackerNotification(
+ defenderName: "Drudge", damageType: 0u,
+ damage: 8u, damagePercent: 0.1f);
+
+ var events = vm.CombatEvents.ToList();
+ Assert.Single(events);
+ Assert.Equal(CombatEventKind.Info, events[0].Kind);
+ Assert.Contains("Drudge", events[0].Text);
+ }
+
+ [Fact]
+ public void EvadedIncoming_AppendsWarnEvent()
+ {
+ var combat = new CombatState();
+ var vm = NewVm(combat);
+
+ combat.OnEvasionDefenderNotification("Mosswart");
+
+ var events = vm.CombatEvents.ToList();
+ Assert.Single(events);
+ Assert.Equal(CombatEventKind.Warn, events[0].Kind);
+ Assert.Contains("Mosswart", events[0].Text);
+ }
+
+ [Fact]
+ public void CombatEventRing_CapsAtMax_DropsOldest()
+ {
+ var combat = new CombatState();
+ var vm = NewVm(combat);
+
+ // Pump well past the cap (25). The oldest entries must drop out.
+ for (int i = 0; i < 40; i++)
+ {
+ combat.OnAttackerNotification(
+ defenderName: $"Foe{i}", damageType: 0u,
+ damage: (uint)i, damagePercent: 0.1f);
+ }
+
+ var events = vm.CombatEvents.ToList();
+ Assert.Equal(DebugVM.MaxCombatEvents, events.Count);
+ // The newest entry must still be present.
+ Assert.Contains(events, e => e.Text.Contains("Foe39"));
+ // The oldest must have dropped.
+ Assert.DoesNotContain(events, e => e.Text.Contains("Foe0,") || e.Text == "Foe0");
+ Assert.DoesNotContain(events, e => e.Text.Contains("Foe5"));
+ }
+
+ [Fact]
+ public void Toast_RingCapsAtMaxRecent()
+ {
+ var vm = NewVm();
+
+ for (int i = 0; i < 30; i++)
+ vm.AddToast($"toast-{i}");
+
+ var toasts = vm.RecentToasts.ToList();
+ Assert.Equal(DebugVM.MaxRecentToasts, toasts.Count);
+ Assert.Contains(toasts, t => t.Text == "toast-29");
+ Assert.DoesNotContain(toasts, t => t.Text == "toast-0");
+ }
+
+ [Fact]
+ public void Toast_PreservesKind()
+ {
+ var vm = NewVm();
+ vm.AddToast("warning!", ToastKind.Warn);
+ vm.AddToast("error!", ToastKind.Error);
+
+ var toasts = vm.RecentToasts.ToList();
+ Assert.Equal(2, toasts.Count);
+ Assert.Contains(toasts, t => t.Text == "warning!" && t.Kind == ToastKind.Warn);
+ Assert.Contains(toasts, t => t.Text == "error!" && t.Kind == ToastKind.Error);
+ }
+
+ [Fact]
+ public void DiagnosticFlags_DefaultFalse_RoundTripIndependently()
+ {
+ var vm = NewVm();
+ Assert.False(vm.DumpMotion);
+ Assert.False(vm.DumpVitals);
+ Assert.False(vm.DumpOpcodes);
+ Assert.False(vm.DumpSky);
+
+ vm.DumpMotion = true;
+ Assert.True(vm.DumpMotion);
+ Assert.False(vm.DumpVitals);
+ Assert.False(vm.DumpOpcodes);
+ Assert.False(vm.DumpSky);
+
+ vm.DumpSky = true;
+ vm.DumpMotion = false;
+ Assert.False(vm.DumpMotion);
+ Assert.True(vm.DumpSky);
+ }
+
+ [Fact]
+ public void ToggleFlags_DoNotAffectCombatRing()
+ {
+ var combat = new CombatState();
+ var vm = NewVm(combat);
+
+ combat.OnAttackerNotification("X", 0u, 1u, 0.1f);
+ Assert.Single(vm.CombatEvents);
+
+ vm.DumpMotion = true;
+ vm.DumpVitals = true;
+ Assert.Single(vm.CombatEvents);
+ }
+
+ [Fact]
+ public void ActionHooks_InvokeSuppliedDelegates()
+ {
+ // The panel needs to invoke "cycle time", "cycle weather", "toggle
+ // collision wires" actions when the corresponding Button is
+ // clicked. The VM exposes these as Action; the panel calls them.
+ int timeHits = 0, weatherHits = 0, wireHits = 0;
+ var vm = NewVm();
+ vm.CycleTimeOfDay = () => timeHits++;
+ vm.CycleWeather = () => weatherHits++;
+ vm.ToggleCollisionWires = () => wireHits++;
+
+ vm.CycleTimeOfDay?.Invoke();
+ vm.CycleTimeOfDay?.Invoke();
+ vm.CycleWeather?.Invoke();
+ vm.ToggleCollisionWires?.Invoke();
+
+ Assert.Equal(2, timeHits);
+ Assert.Equal(1, weatherHits);
+ Assert.Equal(1, wireHits);
+ }
+}