acdream/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
Erik 66dc23e087 feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction
Adds ACDREAM_PROBE_BUILDING — a read-only per-shadow-entry probe that
captures full BSP collision evidence whenever TransitionTypes.FindObjCollisions
attributes a hit (via the existing L.2a slice 3 chain). One multi-line
[resolve-bldg] entry per attributed hit: partIdx, hasPhys, bspR vs
vAabbR, world-space entOrigin_lb, and the actual hit polygon's vertices
in both object-local and world space.

Paired with a one-time [entity-source] line at every ShadowObjects.Register
call site in GameWindow so entityId from a probe line is greppable to its
WorldEntity source within a single log file.

Plumbing: BSPQuery writes the resolved hit polygon to a new
PhysicsDiagnostics.LastBspHitPoly side-channel at the 5 SetCollisionNormal
sites in Paths 5/6 + CollideWithPt. TransitionTypes clears that field
before each shadow-entry dispatch and reads it back at the L.2a slice 3
attribution site to emit the probe line.

Spec component 4 originally described an out ResolvedPolygon? parameter
on BSPQuery.FindCollisions; the static side-channel achieves the same
observable behavior without plumbing through BSPQuery's recursive private
methods. Deviation noted in PhysicsDiagnostics.LastBspHitPoly's XML doc.

Reframes the plan-of-record's L.2d sub-direction paragraph: the 2026-05-12
handoff proposed porting CBuildingObj + per-cell walkability, but ACE
BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260
show find_building_collisions is one BSP test on Parts[0]. Per-cell
walkability belongs to L.2e, not L.2d. L.2d slice 1 is the diagnostic;
slice 2 is the actual fix scoped from slice 1's evidence (one of three
hypotheses: wrong BSP loaded / over-registered parts / BSPQuery flaw).

Tests: 2 synthetic unit tests in PhysicsDiagnosticsTests.cs pin the
static API contract that the BSPQuery → side-channel → TransitionTypes
emission chain depends on. The multi-line line format itself is verified
by acceptance criterion 2 (live Holtburg-doorway capture) — covering it
here would require a heavy PhysicsEngine + Transition fixture for a
diagnostic-only emission.

Verified: dotnet build green; the 2 new tests pass; the 8 pre-existing
test failures listed in the L.2a handoff (MotionInterpreter GetMaxSpeed_*,
PositionManager.ComputeOffset_BothActive_Combined,
PlayerMovementController.Update_ForwardInput_*, Dispatcher.W_held_*,
BSPStepUpTests.{D4,C3}) remain failing — none introduced by this slice.

Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
Conformance anchors:
- acclient_2013_pseudo_c.txt:701260 (CBuildingObj::find_building_collisions)
- acclient_2013_pseudo_c.txt:323725 (BSPTREE::find_collisions)
- ACE references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs:39-52

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:14:34 +02:00

339 lines
16 KiB
C#

using System.Numerics;
using AcDream.Core.Combat;
using AcDream.Core.Physics;
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
{
/// <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 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)
{
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) ───────────────
/// <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;
}
// ── 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();
}
}