fix #434: delete the unreachable DebugPanel/DebugVM surface and the comments that advertised it

DebugPanel and DebugVM have not been constructible since Campaign V slice
V11 removed the ImGui frontend that hosted them: nothing in src/ ever
called their constructors, only a test did. Two consequences, both fixed
here — 35 environment reads inside them were unreachable, and roughly forty
XML doc comments across the diagnostics owners promised a runtime checkbox
that no longer exists. A flag documented as runtime-toggleable when it is
startup-only sends the next investigation down a path that cannot work.

Deleted DebugPanel.cs (340 lines), DebugVM.cs (548) and DebugVMTests.cs
(327). Corrected the surviving claims in PhysicsDiagnostics,
RenderingDiagnostics, CameraDiagnostics, PhysicsEngine and GameWindow to say
what is actually true: these flags are set from the environment at startup
or by direct assignment.

The one real dependant was CombatFeedbackSlot, whose binding target was
DebugVM. It now takes a plain Action<string>, which removes the dependency
without changing behavior — and makes visible that there is no behavior:
nothing binds the slot, so the combat refusals it carries ("No monster
target", "Enter melee or missile combat first") have been discarded all
along. Filed as #436 and pinned by a test, rather than papered over with an
invented chat message; the retail text and channel need the oracle first.

Deliberately untouched: F1's AcdreamToggleDebugPanel binding, which
GameplayInputCommandController consumes as a documented no-op so the key
does not fall through to a lower input scope; and the
DebugVmRenderFactsPublisher / DevToolsRuntimeSources chain, which is still
wired into production composition and deserves its own dead-code pass
instead of being pulled into this one.

Full hermetic suite 15,333 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 10:58:59 +02:00
parent e77dd7c413
commit 05bfe8d162
11 changed files with 129 additions and 1294 deletions

View file

@ -76,9 +76,53 @@ id list), so deletion order matters.
---
## #434 — The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live
## #436 — Combat refusal text ("No monster target") is silently dropped
**Status:** OPEN
**Severity:** MEDIUM (missing user feedback on a common action)
**Filed:** 2026-08-24 (exposed by #434's dead-code removal)
**Component:** combat / chat presentation
**Symptom:** `LiveCombatAttackOperations` produces two refusal messages —
`"Enter melee or missile combat first"` and `"No monster target"` — and
hands them to `CombatFeedbackSlot.Show`. Nothing in `src/` ever binds a
target to that slot, so **both messages go nowhere**. Attacking with no
target, or without a combat mode, gives the player no explanation at all.
**How it got here:** the slot's binding target used to be the developer
`DebugVM`, which Campaign V slice V11 left unreachable (see #434). Nothing
noticed because the drop is silent — `Show` is a null-conditional invoke.
#434 converted the seam to a plain `Action<string>` so it no longer depends
on deleted code, and pinned the current drop-on-the-floor behavior in
`CombatFeedbackSlotTests.AnUnboundSlotDropsItsMessages` so the day it gets a
real binder, that test is what changes.
**Fix shape:** route the slot to the chat window, where retail puts this
text. Needs the retail oracle first: confirm the exact strings and their
LogTextType/channel (`claude-memory/project_chat_digest.md` has the color
and channel map) rather than inventing wording — retail's own text may
differ from these two placeholder strings.
---
## #434 — CLOSED: The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live
**Status:** CLOSED 2026-08-24. Deleted `DebugPanel.cs` (340 lines),
`DebugVM.cs` (548) and `DebugVMTests.cs` (327) — 1,215 lines. Converted the
one real dependant (`CombatFeedbackSlot`) to a delegate seam, which exposed
#436. Corrected every false "runtime-toggleable via the DebugPanel" claim in
`PhysicsDiagnostics`, `RenderingDiagnostics`, `CameraDiagnostics`,
`PhysicsEngine` and `GameWindow`. Full hermetic suite 15,333 passed / 0
failed. **Deliberately left alone:** F1's `AcdreamToggleDebugPanel` binding,
which `GameplayInputCommandController` consumes as a documented no-op on
purpose (so the key does not fall through to a lower scope); and the
`DebugVmRenderFactsPublisher` / `DevToolsRuntimeSources` chain, which is
still wired into production composition and needs its own dead-code pass
rather than being dragged into a documentation cleanup.
**Original report follows.**
**Status (original):** OPEN
**Severity:** LOW (no runtime defect; a documentation-truth and dead-code problem)
**Filed:** 2026-08-24 (found during the launch-options audit)
**Component:** UI.Abstractions / diagnostics ownership

View file

@ -66,27 +66,39 @@ internal interface ICombatFeedbackSink
void Show(string message);
}
/// <summary>
/// Routes combat refusal text ("No monster target") to whichever surface is
/// bound to show it.
/// </summary>
/// <remarks>
/// #434: the bound target used to be the developer <c>DebugVM</c>, which
/// Campaign V slice V11 left unreachable — nothing has constructed it since,
/// so both messages below have been going nowhere. The binding target is now a
/// plain delegate so this seam no longer depends on that dead class, but it
/// still has no production binder: wiring it to the chat window, where retail
/// puts this text, is #436.
/// </remarks>
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
{
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel;
private Action<string>? _target;
public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
public void Bind(Action<string> target)
{
ArgumentNullException.ThrowIfNull(viewModel);
if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel))
ArgumentNullException.ThrowIfNull(target);
if (_target is not null && !ReferenceEquals(_target, target))
throw new InvalidOperationException(
"Combat feedback is already bound to a developer view model.");
_viewModel = viewModel;
"Combat feedback is already bound to a presentation target.");
_target = target;
}
public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
public void Unbind(Action<string> target)
{
ArgumentNullException.ThrowIfNull(viewModel);
if (ReferenceEquals(_viewModel, viewModel))
_viewModel = null;
ArgumentNullException.ThrowIfNull(target);
if (ReferenceEquals(_target, target))
_target = null;
}
public void Show(string message) => _viewModel?.AddToast(message);
public void Show(string message) => _target?.Invoke(message);
}
internal sealed class CombatAttackOperationsSlot

View file

@ -134,8 +134,8 @@ public sealed class GameWindow :
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
// wireframes are noisy outdoors and confuse first-time users into
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
// → Diagnostics → "Toggle collision wires" button toggles too.
// thinking they're a rendering bug. Ctrl+F2 toggles. (The DebugPanel
// button that also toggled it is gone — #434.)
private readonly AcDream.App.Rendering.WorldSceneDebugState
_worldSceneDebugState = new();

View file

@ -6,10 +6,12 @@ namespace AcDream.Core.Physics;
/// <summary>
/// L.2a slice 1 (2026-05-12) — runtime-toggleable physics probe flags.
/// Initialized from env vars at process start; flippable at runtime via
/// the DebugPanel mirror (or by direct assignment). Log call sites read
/// these statics so a checkbox toggle takes effect on the next resolve
/// without relaunching.
/// Initialized from env vars at process start; flippable at runtime by
/// direct assignment. Log call sites read these statics so a change takes
/// effect on the next resolve without relaunching. (#434: these flags used
/// to have a DebugPanel checkbox mirror. That panel has been unreachable
/// since Campaign V slice V11 removed its ImGui host, so every flag here is
/// startup-or-assignment only.)
///
/// <para>
/// L.2d slice 1 (2026-05-13) adds <see cref="ProbeBuildingEnabled"/> +
@ -157,7 +159,7 @@ public static class PhysicsDiagnostics
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_BUILDING=1</c>. Mirrorable
/// via <c>DebugVM.ProbeBuilding</c> when <c>ACDREAM_DEVTOOLS=1</c>.
/// by direct assignment (its DebugVM mirror is gone — #434).
/// </para>
///
/// <para>
@ -891,7 +893,7 @@ public static class PhysicsDiagnostics
/// </para>
///
/// <para>Toggle via env var <c>ACDREAM_PROBE_USEABILITY_FALLBACK=1</c>
/// or DebugPanel checkbox.</para>
/// by direct assignment.</para>
/// </summary>
public static bool ProbeUseabilityFallbackEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1";
@ -915,7 +917,7 @@ public static class PhysicsDiagnostics
/// the post-collision velocity disagrees with retail.</description></item>
/// </list>
/// Initial state from <c>ACDREAM_DUMP_STEEP_ROOF=1</c>. Runtime-toggleable
/// via the property setter; not yet wired to a DebugPanel checkbox (open
/// via the property setter (open
/// follow-up if a debugging session calls for it).
/// </summary>
public static bool DumpSteepRoofEnabled { get; set; } =
@ -940,7 +942,7 @@ public static class PhysicsDiagnostics
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
/// Runtime-toggleable via DebugPanel.
/// Runtime-toggleable by direct assignment.
/// </para>
///
/// <para>
@ -967,7 +969,7 @@ public static class PhysicsDiagnostics
/// zero poly refs, candidate (b)/(d).</description></item>
/// </list>
/// This diagnostic fires at most once per EnvCell (cache is no-op after
/// first population). It does NOT have a DebugPanel mirror yet — this is
/// first population). This is
/// a one-shot capture tool, not a persistent toggle. Promote to full
/// infrastructure after the root cause is identified.
///
@ -1019,7 +1021,7 @@ public static class PhysicsDiagnostics
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_WALK_MISS=1</c>.
/// No DebugPanel mirror — one-shot diagnostic.
/// One-shot diagnostic.
/// </para>
///
/// <para>
@ -1047,7 +1049,7 @@ public static class PhysicsDiagnostics
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_PUSH_BACK=1</c>.
/// Runtime-toggleable via DebugVM mirror.
/// Runtime-toggleable by direct assignment.
/// </para>
///
/// <para>
@ -2034,7 +2036,7 @@ public static class PhysicsDiagnostics
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_STEP_WALK=1</c>. One-shot
/// diagnostic; no DebugPanel mirror until the root cause is identified.
/// diagnostic.
/// </para>
/// </summary>
public static bool ProbeStepWalkEnabled { get; set; } =

View file

@ -2316,7 +2316,7 @@ public sealed class PhysicsEngine
// L.2a slice 1 (2026-05-12): general-purpose resolver probe.
// One line per call when PhysicsDiagnostics.ProbeResolveEnabled
// is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
// DebugPanel checkbox flipped at runtime). Captures every
// property assigned at runtime). Captures every
// dimension L.2 cares about: input/output position, input/output
// cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
// status, wall normal if hit, walkable polygon valid. Zero cost

View file

@ -6,7 +6,8 @@ namespace AcDream.Core.Rendering;
/// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors
/// the <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/> pattern:
/// static fields seeded from env vars at process start, runtime-settable
/// via property setters that the DebugPanel writes to.
/// via property setters. (#434: the DebugPanel that used to write them has
/// been unreachable since Campaign V slice V11.)
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
@ -21,7 +22,7 @@ public static class CameraDiagnostics
/// <c>AcDream.App.Rendering.ChaseCamera</c> rigid-follow camera is.
/// Initial state from <c>ACDREAM_RETAIL_CHASE</c> — default-on if
/// unset, off only when explicitly set to <c>"0"</c>. The legacy
/// camera stays available via the DebugPanel toggle pending the
/// camera stays available by assigning this property pending the
/// follow-up deletion commit.
/// </summary>
public static bool UseRetailChaseCamera { get; set; } =

View file

@ -8,9 +8,11 @@ namespace AcDream.Core.Rendering;
/// <summary>
/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
/// rendering pipeline. Initialized from env vars at process start;
/// flippable at runtime via the DebugPanel mirror. Log call sites read
/// these statics so a checkbox toggle takes effect on the next frame
/// without relaunching.
/// flippable at runtime by direct assignment. Log call sites read these
/// statics so a change takes effect on the next frame without relaunching.
/// (#434: these used to have a DebugPanel checkbox mirror. That panel has
/// been unreachable since Campaign V slice V11 removed its ImGui host, so
/// every flag here is startup-or-assignment only.)
///
/// <para>
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
@ -90,8 +92,8 @@ public static class RenderingDiagnostics
/// Initial state from <c>ACDREAM_PROBE_VIS=1</c>.
/// <para>
/// Phase U.2d (2026-05-30) repurposed this flag from the abandoned A8
/// two-pipe stencil pass to the Phase U unified pipeline. The env var name +
/// the DebugPanel mirror (<c>DebugVM.ProbeVisibility</c>) are unchanged.
/// two-pipe stencil pass to the Phase U unified pipeline. The env var name
/// is unchanged (its DebugPanel mirror is gone — #434).
/// </para>
/// </summary>
public static bool ProbeVisibilityEnabled { get; set; } =
@ -352,8 +354,8 @@ public static class RenderingDiagnostics
/// (the intensity-100 portal purples + the viewer fill off; statics stay);
/// 3 = raw vLit visualization in the fragment shader (texture ignored).
/// Discriminates lighting-driven stripes (gone at 1/2, visible in the field
/// at 3) from texture/per-pixel machinery (survive 1). Settable for a
/// future DebugPanel mirror.
/// at 3) from texture/per-pixel machinery (survive 1). Settable at
/// runtime by direct assignment.
/// </summary>
public static int LightDebugMode { get; set; } =
int.TryParse(
@ -771,7 +773,7 @@ public static class RenderingDiagnostics
/// <c>FrameProfiler</c>'s own class doc. Every backend reports GPU time
/// through <c>FrameProfiler.RecordGpuSample</c>.
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
/// via the DebugPanel mirror (<c>DebugVM.FrameProf</c>).
/// by direct assignment (its DebugPanel mirror is gone — #434).
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public static bool FrameProfEnabled { get; set; } =

View file

@ -1,340 +0,0 @@
using System.Numerics;
namespace AcDream.UI.Abstractions.Panels.Debug;
/// <summary>
/// The Phase I.2 debug panel — single ImGui window with collapsing-header
/// sections that replace the old custom <c>DebugOverlay</c>'s six floating
/// panels (Info / Stats / Help / Compass / Chat / Event) plus the toast
/// surface. Reads through <see cref="DebugVM"/> so values are always live.
///
/// <para>
/// Layout: Player Info, Performance, Compass, Help, Combat events, Recent
/// toasts, Diagnostics. Each section is a <c>CollapsingHeader</c>;
/// importance-ranked sections default open, niche ones default closed.
/// </para>
///
/// <para>
/// Reuses the I.1 widget extensions only; never imports a backend
/// namespace. Same constraints as <c>VitalsPanel</c> and <c>ChatPanel</c>.
/// </para>
/// </summary>
public sealed class DebugPanel : IPanel
{
private readonly DebugVM _vm;
public DebugPanel(DebugVM vm)
{
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
}
/// <inheritdoc />
public string Id => "acdream.debug";
/// <inheritdoc />
public string Title => "Debug";
/// <inheritdoc />
public bool IsVisible { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
private static readonly (string Key, string Action)[] Keybinds =
{
// K-fix4 (2026-04-26): refreshed for the retail-default keymap +
// Phase K input-pipeline bindings. F1-F12 alone are retail panel
// toggles; acdream debug actions live behind Ctrl+F* to avoid
// retail conflicts.
("Esc", "exit fly / close window"),
("F11", "open Settings (key rebinding etc.)"),
("Ctrl+Shift+F", "toggle free-fly camera"),
("Ctrl+F1", "toggle this debug panel"),
("Ctrl+F2", "toggle collision wireframes"),
("Ctrl+F3", "console dump (pos + nearby objects)"),
("Ctrl+F7", "cycle time-of-day override"),
("Ctrl+F8 / F9", "mouse sensitivity slower / faster"),
("Ctrl+F10", "cycle weather"),
("W / X", "run forward / backward"),
("A / D", "turn left / right"),
("Z / C", "strafe left / right"),
("Q", "autorun toggle"),
("Shift", "walk modifier (default = run)"),
("Space", "jump (hold to charge)"),
("Y G H B", "stand / sit / crouch / lie"),
("Hold MMB", "instant mouse-look"),
("Hold RMB", "free orbit camera around player"),
("Wheel", "zoom chase camera in / out"),
("Tab", "focus chat input"),
};
/// <inheritdoc />
public void Render(PanelContext ctx, IPanelRenderer renderer)
{
if (!renderer.Begin(Title))
{
renderer.End();
return;
}
DrawPlayerInfo(renderer);
DrawChaseCamera(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 DrawChaseCamera(IPanelRenderer r)
{
if (!r.CollapsingHeader("Chase camera", defaultOpen: true)) return;
bool useRetail = _vm.UseRetailChaseCamera;
bool alignSlope = _vm.CameraAlignToSlope;
float tStiff = _vm.CameraTranslationStiffness;
float rStiff = _vm.CameraRotationStiffness;
float lpWindow = _vm.CameraMouseLowPassWindowSec;
float adjSpeed = _vm.CameraAdjustmentSpeed;
if (r.Checkbox("Use retail chase camera (env: ACDREAM_RETAIL_CHASE)", ref useRetail))
_vm.UseRetailChaseCamera = useRetail;
if (r.Checkbox("Align to slope (env: ACDREAM_CAMERA_ALIGN_SLOPE)", ref alignSlope))
_vm.CameraAlignToSlope = alignSlope;
if (r.SliderFloat("Translation stiffness", ref tStiff, 0.05f, 1.0f))
_vm.CameraTranslationStiffness = tStiff;
if (r.SliderFloat("Rotation stiffness", ref rStiff, 0.05f, 1.0f))
_vm.CameraRotationStiffness = rStiff;
if (r.SliderFloat("Mouse low-pass window (s)", ref lpWindow, 0.0f, 0.5f))
_vm.CameraMouseLowPassWindowSec = lpWindow;
if (r.SliderFloat("Adjustment speed (units/s)", ref adjSpeed, 10f, 80f))
_vm.CameraAdjustmentSpeed = adjSpeed;
}
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;
bool probeResolve = _vm.ProbeResolve;
bool probeCell = _vm.ProbeCell;
bool probeBuilding = _vm.ProbeBuilding;
bool probeAutoWalk = _vm.ProbeAutoWalk;
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;
// L.2a slice 1 (2026-05-12): unlike the four above, these
// forward to PhysicsDiagnostics so a toggle takes effect live.
if (r.Checkbox("Probe resolve (ACDREAM_PROBE_RESOLVE)", ref probeResolve)) _vm.ProbeResolve = probeResolve;
if (r.Checkbox("Probe cell-transit (ACDREAM_PROBE_CELL)",ref probeCell)) _vm.ProbeCell = probeCell;
// L.2d slice 1 (2026-05-13): heavy per-hit BSP diagnostic for
// doorway / building shape-fidelity work. Emits multi-line
// [resolve-bldg] entries; expect log volume to spike at walls.
if (r.Checkbox("Probe BSP hits (ACDREAM_PROBE_BUILDING, slow)",
ref probeBuilding)) _vm.ProbeBuilding = probeBuilding;
// B.6 slice 1 (2026-05-14): local-player auto-walk trace for issue #63.
// Low volume — only the local player's UM/UP/Use/PickUp events emit.
if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)",
ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;
// MP0 (2026-07-05): permanent frame profiler toggle — not a
// throwaway investigation probe, so it lives with the other
// always-available diagnostics rather than a dated section.
bool frameProf = _vm.FrameProf;
if (r.Checkbox("Frame profiler ([frame-prof])", ref frameProf)) _vm.FrameProf = frameProf;
// ── Indoor rendering diagnostics (2026-05-19) ───────────────
// Pinpoint where the EnvCell rendering chain breaks for
// hypothesis-driven Phase 2 fix. Spec:
// docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
r.Separator();
r.Text("Indoor rendering (envCell):");
bool probeIndoorAll = _vm.ProbeIndoorAll;
bool probeIndoorWalk = _vm.ProbeIndoorWalk;
bool probeIndoorLookup = _vm.ProbeIndoorLookup;
bool probeIndoorUpload = _vm.ProbeIndoorUpload;
bool probeIndoorXform = _vm.ProbeIndoorXform;
bool probeIndoorCull = _vm.ProbeIndoorCull;
if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll;
if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk;
if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup;
if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload;
if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform;
if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull;
bool probeIndoorBsp = _vm.ProbeIndoorBsp;
if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp;
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();
// Phase K.2 — explicit free-fly toggle button. Mirrors the
// legacy F-key alias but is discoverable to users who haven't
// memorized the Ctrl+F* debug bindings. Action handle owned
// by GameWindow; null-safe for tests / offline.
if (r.Button("Toggle Free-Fly Mode")) _vm.ToggleFlyMode?.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];
}
}

View file

@ -1,548 +0,0 @@
using System.Numerics;
using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
namespace AcDream.UI.Abstractions.Panels.Debug;
/// <summary>
/// Severity tag for a single combat-event line in the
/// <see cref="DebugVM.CombatEvents"/> ring. The panel reads this to pick
/// a <c>TextColored</c> rgba per row (yellow info / red warn / deep-red
/// error). Mirrors the same tri-tone the chat panel uses for combat
/// (Phase I.7).
/// </summary>
public enum CombatEventKind
{
/// <summary>You dealt damage / landed a hit. Yellow.</summary>
Info,
/// <summary>An incoming hit you evaded. Red.</summary>
Warn,
/// <summary>You took damage. Deep red.</summary>
Error,
}
/// <summary>
/// Single typed entry in the combat-events ring. <see cref="Timestamp"/>
/// is captured at append time so a future panel revision can fade old
/// entries; for I.2 the panel just renders the text + rgba.
/// </summary>
public readonly record struct CombatEventLine(
DateTime Timestamp,
CombatEventKind Kind,
string Text);
/// <summary>
/// Severity tag for a transient toast message. Mirrors
/// <see cref="CombatEventKind"/> but lives in its own enum so the toast
/// surface can grow (e.g. an "OK" green) without dragging the combat
/// surface along.
/// </summary>
public enum ToastKind
{
Info,
Warn,
Error,
}
/// <summary>Single transient toast message kept in the recent-toasts ring.</summary>
public readonly record struct ToastMessage(
DateTime Timestamp,
ToastKind Kind,
string Text);
/// <summary>
/// ViewModel for the Phase I.2 <see cref="DebugPanel"/>. Read-through
/// (no caching): every property forwards to a <c>Func&lt;T&gt;</c> that
/// the host (<c>GameWindow</c>) wires up at construction. Internal
/// state is limited to (a) the combat-event ring buffer, populated via a
/// self-subscription to <see cref="CombatState"/>'s typed events
/// (replacing the old <c>DebugOverlay.BindCombat</c>); (b) the toast
/// ring; (c) the diagnostic-flag bools the panel exposes as checkboxes.
///
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class DebugVM : IDisposable
{
/// <summary>Maximum number of combat-event lines kept in the ring.</summary>
public const int MaxCombatEvents = 25;
/// <summary>Maximum number of recent toast messages kept in the ring.</summary>
public const int MaxRecentToasts = 25;
private readonly Func<Vector3> _getPlayerPosition;
private readonly Func<float> _getPlayerHeadingDeg;
private readonly Func<uint> _getPlayerCellId;
private readonly Func<bool> _getPlayerOnGround;
private readonly Func<bool> _getInPlayerMode;
private readonly Func<bool> _getInFlyMode;
private readonly Func<float> _getVerticalVelocity;
private readonly Func<int> _getEntityCount;
private readonly Func<int> _getAnimatedCount;
private readonly Func<int> _getLandblocksVisible;
private readonly Func<int> _getLandblocksTotal;
private readonly Func<int> _getShadowObjectCount;
private readonly Func<float> _getNearestObjDist;
private readonly Func<string> _getNearestObjLabel;
private readonly Func<bool> _getColliding;
private readonly Func<bool> _getDebugWireframes;
private readonly Func<int> _getStreamingRadius;
private readonly Func<float> _getMouseSensitivity;
private readonly Func<float> _getChaseDistance;
private readonly Func<bool> _getRmbOrbit;
private readonly Func<string> _getHourName;
private readonly Func<float> _getDayFraction;
private readonly Func<string> _getWeather;
private readonly Func<int> _getActiveLights;
private readonly Func<int> _getRegisteredLights;
private readonly Func<int> _getParticleCount;
private readonly Func<float> _getFps;
private readonly Func<float> _getFrameMs;
private readonly CombatState _combat;
private bool _disposed;
private readonly Queue<CombatEventLine> _combatEvents = new();
private readonly Queue<ToastMessage> _toasts = new();
/// <summary>
/// Build a VM bound to live data sources. Every <c>Func</c> 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.
/// </summary>
public DebugVM(
Func<Vector3> getPlayerPosition,
Func<float> getPlayerHeadingDeg,
Func<uint> getPlayerCellId,
Func<bool> getPlayerOnGround,
Func<bool> getInPlayerMode,
Func<bool> getInFlyMode,
Func<float> getVerticalVelocity,
Func<int> getEntityCount,
Func<int> getAnimatedCount,
Func<int> getLandblocksVisible,
Func<int> getLandblocksTotal,
Func<int> getShadowObjectCount,
Func<float> getNearestObjDist,
Func<string> getNearestObjLabel,
Func<bool> getColliding,
Func<bool> getDebugWireframes,
Func<int> getStreamingRadius,
Func<float> getMouseSensitivity,
Func<float> getChaseDistance,
Func<bool> getRmbOrbit,
Func<string> getHourName,
Func<float> getDayFraction,
Func<string> getWeather,
Func<int> getActiveLights,
Func<int> getRegisteredLights,
Func<int> getParticleCount,
Func<float> getFps,
Func<float> getFrameMs,
CombatState combat)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition));
_getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg));
_getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId));
_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 += OnDamageTaken;
_combat.DamageDealtAccepted += OnDamageDealt;
_combat.EvadedIncoming += OnEvadedIncoming;
_combat.MissedOutgoing += OnMissedOutgoing;
_combat.AttackDone += OnAttackDone;
_combat.KillLanded += OnKillLanded;
}
// ── 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) ───────────────
/// <summary>Mirror of <c>ACDREAM_DUMP_MOTION</c>; flipped at runtime via the panel.</summary>
public bool DumpMotion { get; set; }
/// <summary>Mirror of <c>ACDREAM_DUMP_VITALS</c>.</summary>
public bool DumpVitals { get; set; }
/// <summary>Mirror of <c>ACDREAM_DUMP_OPCODES</c>.</summary>
public bool DumpOpcodes { get; set; }
/// <summary>Mirror of <c>ACDREAM_DUMP_SKY</c>.</summary>
public bool DumpSky { get; set; }
// L.2a slice 1 (2026-05-12): unlike DumpMotion/Vitals/Opcodes/Sky
// above (which are display-only mirrors of sticky-at-startup env
// vars), these forward directly to the PhysicsDiagnostics statics,
// so checkbox toggles take effect on the next physics resolve.
/// <summary>
/// Runtime mirror of <c>PhysicsDiagnostics.ProbeResolveEnabled</c>
/// (env var <c>ACDREAM_PROBE_RESOLVE</c>). Toggling here flips the
/// resolver probe live — no relaunch required.
/// </summary>
public bool ProbeResolve
{
get => PhysicsDiagnostics.ProbeResolveEnabled;
set => PhysicsDiagnostics.ProbeResolveEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>PhysicsDiagnostics.ProbeCellEnabled</c>
/// (env var <c>ACDREAM_PROBE_CELL</c>). Toggling here flips the
/// cell-transit probe live.
/// </summary>
public bool ProbeCell
{
get => PhysicsDiagnostics.ProbeCellEnabled;
set => PhysicsDiagnostics.ProbeCellEnabled = value;
}
/// <summary>
/// L.2d slice 1 (2026-05-13). Runtime mirror of
/// <c>PhysicsDiagnostics.ProbeBuildingEnabled</c> (env var
/// <c>ACDREAM_PROBE_BUILDING</c>). Toggling here flips the per-hit
/// <c>[resolve-bldg]</c> diagnostic + the registration-time
/// <c>[entity-source]</c> log lines. Heavy when enabled — emits one
/// multi-line entry per BSP hit per physics tick.
/// </summary>
public bool ProbeBuilding
{
get => PhysicsDiagnostics.ProbeBuildingEnabled;
set => PhysicsDiagnostics.ProbeBuildingEnabled = value;
}
/// <summary>
/// B.6 slice 1 (2026-05-14). Runtime mirror of
/// <c>PhysicsDiagnostics.ProbeAutoWalkEnabled</c> (env var
/// <c>ACDREAM_PROBE_AUTOWALK</c>). Toggling here flips the
/// <c>[autowalk-out]</c> / <c>[autowalk-mt]</c> / <c>[autowalk-up]</c>
/// trace used to characterize ACE's behavior during a server-
/// initiated auto-walk (issue #63). Low volume when off — only the
/// local player's events are filtered through the probe.
/// </summary>
public bool ProbeAutoWalk
{
get => PhysicsDiagnostics.ProbeAutoWalkEnabled;
set => PhysicsDiagnostics.ProbeAutoWalkEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.FrameProfEnabled</c>
/// (env var <c>ACDREAM_FRAME_PROF</c>). Toggling here starts/stops the
/// [frame-prof] 5-second report live — no relaunch required.
/// </summary>
public bool FrameProf
{
get => RenderingDiagnostics.FrameProfEnabled;
set => RenderingDiagnostics.FrameProfEnabled = value;
}
// ── Indoor rendering diagnostics (2026-05-19) ───────────────────
// Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles
// take effect on the next render frame without relaunching.
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorWalkEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_WALK</c>).
/// </summary>
public bool ProbeIndoorWalk
{
get => RenderingDiagnostics.ProbeIndoorWalkEnabled;
set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorLookupEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_LOOKUP</c>).
/// </summary>
public bool ProbeIndoorLookup
{
get => RenderingDiagnostics.ProbeIndoorLookupEnabled;
set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorUploadEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_UPLOAD</c>).
/// </summary>
public bool ProbeIndoorUpload
{
get => RenderingDiagnostics.ProbeIndoorUploadEnabled;
set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorXformEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_XFORM</c>).
/// </summary>
public bool ProbeIndoorXform
{
get => RenderingDiagnostics.ProbeIndoorXformEnabled;
set => RenderingDiagnostics.ProbeIndoorXformEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorCullEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_CULL</c>).
/// </summary>
public bool ProbeIndoorCull
{
get => RenderingDiagnostics.ProbeIndoorCullEnabled;
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
}
/// <summary>
/// Phase A8 (2026-05-25). Runtime mirror of
/// <c>RenderingDiagnostics.ProbeVisibilityEnabled</c>
/// (env var <c>ACDREAM_PROBE_VIS</c>).
/// </summary>
public bool ProbeVisibility
{
get => RenderingDiagnostics.ProbeVisibilityEnabled;
set => RenderingDiagnostics.ProbeVisibilityEnabled = value;
}
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Runtime mirror of
/// <c>PhysicsDiagnostics.ProbeIndoorBspEnabled</c> (env var
/// <c>ACDREAM_PROBE_INDOOR_BSP</c>). Toggling here flips the
/// <c>[indoor-bsp]</c> probe live — no relaunch required.
/// Physics-side companion to the five render-side
/// <c>ProbeIndoor*</c> mirrors directly above.
/// </summary>
public bool ProbeIndoorBsp
{
get => PhysicsDiagnostics.ProbeIndoorBspEnabled;
set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value;
}
/// <summary>
/// Phase A6.P1 cdb probe spike (2026-05-21). Runtime mirror of
/// <see cref="PhysicsDiagnostics.ProbePushBackEnabled"/> (env var
/// <c>ACDREAM_PROBE_PUSH_BACK</c>). Toggling here flips the three
/// <c>[push-back]</c> emission sites live — no relaunch required.
/// </summary>
public bool ProbePushBack
{
get => PhysicsDiagnostics.ProbePushBackEnabled;
set => PhysicsDiagnostics.ProbePushBackEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
/// five indoor probes together. No dedicated env var; set any individual
/// probe env var or use <c>ACDREAM_PROBE_INDOOR_ALL</c> to initialize
/// all five flags on at startup.
/// </summary>
public bool ProbeIndoorAll
{
get => RenderingDiagnostics.IndoorAll;
set => RenderingDiagnostics.IndoorAll = value;
}
// ── Chase camera tunables (forward to CameraDiagnostics) ──────────
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.</summary>
public bool UseRetailChaseCamera
{
get => CameraDiagnostics.UseRetailChaseCamera;
set => CameraDiagnostics.UseRetailChaseCamera = value;
}
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.AlignToSlope"/>.</summary>
public bool CameraAlignToSlope
{
get => CameraDiagnostics.AlignToSlope;
set => CameraDiagnostics.AlignToSlope = value;
}
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.TranslationStiffness"/>.</summary>
public float CameraTranslationStiffness
{
get => CameraDiagnostics.TranslationStiffness;
set => CameraDiagnostics.TranslationStiffness = value;
}
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.RotationStiffness"/>.</summary>
public float CameraRotationStiffness
{
get => CameraDiagnostics.RotationStiffness;
set => CameraDiagnostics.RotationStiffness = value;
}
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.MouseLowPassWindowSec"/>.</summary>
public float CameraMouseLowPassWindowSec
{
get => CameraDiagnostics.MouseLowPassWindowSec;
set => CameraDiagnostics.MouseLowPassWindowSec = value;
}
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.CameraAdjustmentSpeed"/>.</summary>
public float CameraAdjustmentSpeed
{
get => CameraDiagnostics.CameraAdjustmentSpeed;
set => CameraDiagnostics.CameraAdjustmentSpeed = value;
}
// ── Action hooks invoked by panel buttons ──────────────────────────
/// <summary>
/// Cycle the time-of-day debug override (matches the old F7
/// behavior — none → midnight → dawn → noon → dusk → none). Wired
/// by <c>GameWindow</c>; null when no host is available (tests).
/// </summary>
public Action? CycleTimeOfDay { get; set; }
/// <summary>
/// Cycle the weather-kind debug override (matches the old F10
/// behavior — clear → overcast → rain → snow → storm).
/// </summary>
public Action? CycleWeather { get; set; }
/// <summary>
/// Toggle the collision-wires debug renderer. Same effect as the
/// old F2 keybind, which we keep as a hotkey alias.
/// </summary>
public Action? ToggleCollisionWires { get; set; }
/// <summary>
/// Phase K.2 — toggle the free-fly camera. Lets a user opt out of
/// the auto-entered chase camera (e.g. to inspect a remote part of
/// the world without the player following) without needing to find
/// the Ctrl+F* debug binding. Wired by <c>GameWindow</c> to the
/// same routine the legacy F-key fly toggle invokes.
/// </summary>
public Action? ToggleFlyMode { get; set; }
// ── Combat event ring + toast ring ─────────────────────────────────
/// <summary>
/// Snapshot view of the combat-event ring. Oldest-first; the panel
/// can iterate and render each line through <c>TextColored</c>
/// based on <see cref="CombatEventLine.Kind"/>.
/// </summary>
public IReadOnlyCollection<CombatEventLine> CombatEvents => _combatEvents;
/// <summary>Snapshot view of the recent-toasts ring (oldest-first).</summary>
public IReadOnlyCollection<ToastMessage> RecentToasts => _toasts;
/// <summary>
/// Append a toast message to the ring. Cap at
/// <see cref="MaxRecentToasts"/>; oldest entries drop. The panel's
/// "Recent toasts" section reads this; no on-screen flash for I.2.
/// </summary>
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();
}
public void Dispose()
{
if (_disposed)
return;
_combat.DamageTaken -= OnDamageTaken;
_combat.DamageDealtAccepted -= OnDamageDealt;
_combat.EvadedIncoming -= OnEvadedIncoming;
_combat.MissedOutgoing -= OnMissedOutgoing;
_combat.AttackDone -= OnAttackDone;
_combat.KillLanded -= OnKillLanded;
_disposed = true;
}
private void OnDamageTaken(CombatState.DamageIncoming damage) =>
Push(
CombatEventKind.Error,
$"<< {damage.AttackerName} hit you for {damage.Damage}" +
(damage.Critical ? " CRIT!" : string.Empty));
private void OnDamageDealt(CombatState.DamageDealt damage) =>
Push(
CombatEventKind.Info,
$">> you hit {damage.DefenderName} for {damage.Damage}");
private void OnEvadedIncoming(string attacker) =>
Push(CombatEventKind.Warn, $"<< {attacker}'s attack missed you");
private void OnMissedOutgoing(string defender) =>
Push(CombatEventKind.Info, $">> your attack missed {defender}");
private void OnAttackDone(uint _, uint weenieError)
{
if (weenieError != 0)
Push(
CombatEventKind.Error,
$"!! attack failed (error 0x{weenieError:X})");
}
private void OnKillLanded(string victim, uint _) =>
Push(CombatEventKind.Info, $"** you killed {victim}");
}

View file

@ -1,7 +1,4 @@
using System.Numerics;
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Debug;
namespace AcDream.App.Tests.Combat;
@ -11,49 +8,41 @@ public sealed class CombatFeedbackSlotTests
public void ExpectedOwnerUnbindCannotClearReplacement()
{
var slot = new CombatFeedbackSlot();
using DebugVM first = CreateViewModel();
using DebugVM second = CreateViewModel();
List<string> first = [];
List<string> second = [];
Action<string> firstTarget = first.Add;
Action<string> secondTarget = second.Add;
slot.Bind(first);
slot.Unbind(second);
slot.Bind(firstTarget);
slot.Unbind(secondTarget);
slot.Show("first");
Assert.Single(first.RecentToasts);
Assert.Empty(second.RecentToasts);
Assert.Equal(["first"], first);
Assert.Empty(second);
slot.Unbind(first);
slot.Bind(second);
slot.Unbind(firstTarget);
slot.Bind(secondTarget);
slot.Show("second");
Assert.Single(second.RecentToasts);
Assert.Equal(["second"], second);
}
private static DebugVM CreateViewModel() => new(
static () => Vector3.Zero,
static () => 0,
static () => 0,
static () => false,
static () => false,
static () => false,
static () => 0,
static () => 0,
static () => 0,
static () => 0,
static () => 0,
static () => 0,
static () => float.PositiveInfinity,
static () => "-",
static () => false,
static () => false,
static () => 0,
static () => 1,
static () => 0,
static () => false,
static () => "0",
static () => 0,
static () => "Clear",
static () => 0,
static () => 0,
static () => 0,
static () => 60,
static () => 16.7f,
new CombatState());
[Fact]
public void RebindingADifferentTargetWhileBoundIsRejected()
{
var slot = new CombatFeedbackSlot();
slot.Bind(_ => { });
Assert.Throws<InvalidOperationException>(() => slot.Bind(_ => { }));
}
[Fact]
public void AnUnboundSlotDropsItsMessages()
{
// #434/#436: this is the shipped behavior, not an aspiration —
// nothing binds the slot in production, so combat refusal text
// ("No monster target") is discarded. Pinned so the day it gets a
// real binder, this test is the one that has to change.
var slot = new CombatFeedbackSlot();
slot.Show("dropped");
}
}

View file

@ -1,327 +0,0 @@
using System.Numerics;
using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.UI.Abstractions.Panels.Debug;
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
/// <summary>
/// Tests for the Phase I.2 <see cref="DebugVM"/> — read-through ViewModel
/// for the migrated debug panel. Verifies the combat-event subscription
/// (replacing the old <c>DebugOverlay.BindCombat</c>), the toast ring
/// cap, and that the diagnostic-flag bools round-trip without affecting
/// the rest of the VM.
/// </summary>
public sealed class DebugVMTests
{
[Fact]
public void DisposeDetachesEveryCombatSubscriptionExactlyOnce()
{
var combat = new CombatState();
DebugVM vm = NewVm(combat);
combat.OnKillerNotification("first", 1);
Assert.Single(vm.CombatEvents);
vm.Dispose();
vm.Dispose();
combat.OnKillerNotification("second", 2);
combat.OnAttackDone(3, 4);
Assert.Single(vm.CombatEvents);
}
/// <summary>
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every
/// constructor source. Tests that don't care about a particular source
/// just leave it stubbed out.
/// </summary>
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<ArgumentNullException>(() => 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<T>. 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);
}
[Fact]
public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics()
{
var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled;
try
{
var vm = NewVm();
vm.ProbeIndoorBsp = true;
Assert.True(PhysicsDiagnostics.ProbeIndoorBspEnabled);
Assert.True(vm.ProbeIndoorBsp);
vm.ProbeIndoorBsp = false;
Assert.False(PhysicsDiagnostics.ProbeIndoorBspEnabled);
Assert.False(vm.ProbeIndoorBsp);
}
finally
{
PhysicsDiagnostics.ProbeIndoorBspEnabled = originalEnabled;
}
}
}