feat(physics): [cp-write] probe for ContactPlane retention spike

Spike for the next phase of indoor-walking work: confirm/refute the
hypothesis that FindEnvCollisions's indoor branch rewrites the player's
ContactPlane every frame instead of retaining it across frames (retail's
actual behavior). The previous session shipped 6 commits on a wrong
diagnosis; this probe captures the data BEFORE designing the fix.

Two pieces:

1. Add PhysicsDiagnostics.ProbeContactPlaneEnabled flag, gated on
   ACDREAM_PROBE_CONTACT_PLANE=1 (also runtime-toggleable). Helper
   methods LogCpBoolWrite / LogCpPlaneWrite / LogCpCellIdWrite emit one
   [cp-write] line per CP/LKCP field mutation with caller (walked from
   the stack with file+line info) when the value actually changes.

2. Convert the 8 ContactPlane group + LastKnownContactPlane group
   fields on CollisionInfo from public fields to public properties
   with backing fields. Setters call the diagnostic helpers when the
   probe is on; getters/setters are inlined when the flag is off.
   Storage layout unchanged. No call site changes — grep confirmed no
   ref/out passing or sub-field writes.

Build green; tests green at the existing 8-failure baseline (2 BSPStepUp,
6 MotionInterpreter — all unrelated, pre-existing).

Capture command:
  ACDREAM_PROBE_CONTACT_PLANE=1 ACDREAM_PROBE_INDOOR_BSP=1 ACDREAM_DEVTOOLS=1

Spike-only — remove when the retention fix lands and the diagnostic
value is captured in the next phase's spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-20 07:22:55 +02:00
parent 7034be9294
commit 66de00d09a
2 changed files with 175 additions and 8 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
@ -221,4 +222,70 @@ public static class PhysicsDiagnostics
/// </summary>
public static bool ProbeCellCacheEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1";
/// <summary>
/// ContactPlane retention spike (2026-05-20). When true, every write to
/// <c>CollisionInfo.ContactPlane{,Valid,CellId,IsWater}</c> and
/// <c>LastKnownContactPlane{,Valid,CellId,IsWater}</c> emits one
/// <c>[cp-write]</c> line: field, old → new value, caller method (walked
/// from the stack), and source line. Maps the per-frame lifecycle of the
/// contact plane to confirm/refute the hypothesis that
/// <c>FindEnvCollisions</c> indoor branch is rewriting CP every frame
/// instead of retaining it across frames.
///
/// <para>
/// Only logs when the value actually changes (suppresses no-op writes to
/// reduce log volume). Initial state from
/// <c>ACDREAM_PROBE_CONTACT_PLANE=1</c>. Spike-only — remove once the fix
/// lands and the diagnostic value is captured.
/// </para>
/// </summary>
public static bool ProbeContactPlaneEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CONTACT_PLANE") == "1";
public static void LogCpBoolWrite(string field, bool oldValue, bool newValue)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: {oldValue} -> {newValue} caller={caller}"));
}
public static void LogCpPlaneWrite(string field, Plane oldPlane, Plane newPlane)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: n=({oldPlane.Normal.X:F3},{oldPlane.Normal.Y:F3},{oldPlane.Normal.Z:F3}) D={oldPlane.D:F3} -> n=({newPlane.Normal.X:F3},{newPlane.Normal.Y:F3},{newPlane.Normal.Z:F3}) D={newPlane.D:F3} caller={caller}"));
}
public static void LogCpCellIdWrite(string field, uint oldValue, uint newValue)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: 0x{oldValue:X8} -> 0x{newValue:X8} caller={caller}"));
}
/// <summary>
/// Walks the stack to identify the first frame outside <c>CollisionInfo</c>
/// and <c>PhysicsDiagnostics</c> — that's the actual caller writing the
/// ContactPlane field. Format: <c>TypeName.MethodName:line</c> when file
/// info is available, else just <c>TypeName.MethodName</c>. Walked with
/// <c>fileNeeded=true</c> only when the probe flag is on, so zero cost
/// when off.
/// </summary>
private static string GetCpCallerName()
{
// Skip 2: this method + the LogCp*Write helper that called it.
var st = new System.Diagnostics.StackTrace(2, fNeedFileInfo: true);
for (int i = 0; i < st.FrameCount; i++)
{
var f = st.GetFrame(i);
var m = f?.GetMethod();
if (m is null) continue;
var typeName = m.DeclaringType?.Name ?? "?";
if (typeName == "CollisionInfo" || typeName == "PhysicsDiagnostics") continue;
int line = f?.GetFileLineNumber() ?? 0;
return line > 0 ? $"{typeName}.{m.Name}:{line}" : $"{typeName}.{m.Name}";
}
return "?";
}
}

View file

@ -125,15 +125,115 @@ public sealed class ObjectInfo
/// </summary>
public sealed class CollisionInfo
{
public bool ContactPlaneValid;
public Plane ContactPlane;
public uint ContactPlaneCellId;
public bool ContactPlaneIsWater;
// ContactPlane group and LastKnownContactPlane group are properties (not
// fields) so the ContactPlane retention spike (2026-05-20) can log every
// mutation via PhysicsDiagnostics.ProbeContactPlaneEnabled. Backing
// fields keep storage layout identical; getters/setters are inlined by
// the JIT when the probe flag is off. No call site needs to change —
// `ci.ContactPlane = plane` continues to compile unchanged.
private bool _contactPlaneValid;
private Plane _contactPlane;
private uint _contactPlaneCellId;
private bool _contactPlaneIsWater;
public bool LastKnownContactPlaneValid;
public Plane LastKnownContactPlane;
public uint LastKnownContactPlaneCellId;
public bool LastKnownContactPlaneIsWater;
private bool _lastKnownContactPlaneValid;
private Plane _lastKnownContactPlane;
private uint _lastKnownContactPlaneCellId;
private bool _lastKnownContactPlaneIsWater;
public bool ContactPlaneValid
{
get => _contactPlaneValid;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && _contactPlaneValid != value)
PhysicsDiagnostics.LogCpBoolWrite("ContactPlaneValid", _contactPlaneValid, value);
_contactPlaneValid = value;
}
}
public Plane ContactPlane
{
get => _contactPlane;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && !PlaneEquals(_contactPlane, value))
PhysicsDiagnostics.LogCpPlaneWrite("ContactPlane", _contactPlane, value);
_contactPlane = value;
}
}
public uint ContactPlaneCellId
{
get => _contactPlaneCellId;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && _contactPlaneCellId != value)
PhysicsDiagnostics.LogCpCellIdWrite("ContactPlaneCellId", _contactPlaneCellId, value);
_contactPlaneCellId = value;
}
}
public bool ContactPlaneIsWater
{
get => _contactPlaneIsWater;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && _contactPlaneIsWater != value)
PhysicsDiagnostics.LogCpBoolWrite("ContactPlaneIsWater", _contactPlaneIsWater, value);
_contactPlaneIsWater = value;
}
}
public bool LastKnownContactPlaneValid
{
get => _lastKnownContactPlaneValid;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && _lastKnownContactPlaneValid != value)
PhysicsDiagnostics.LogCpBoolWrite("LastKnownContactPlaneValid", _lastKnownContactPlaneValid, value);
_lastKnownContactPlaneValid = value;
}
}
public Plane LastKnownContactPlane
{
get => _lastKnownContactPlane;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && !PlaneEquals(_lastKnownContactPlane, value))
PhysicsDiagnostics.LogCpPlaneWrite("LastKnownContactPlane", _lastKnownContactPlane, value);
_lastKnownContactPlane = value;
}
}
public uint LastKnownContactPlaneCellId
{
get => _lastKnownContactPlaneCellId;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && _lastKnownContactPlaneCellId != value)
PhysicsDiagnostics.LogCpCellIdWrite("LastKnownContactPlaneCellId", _lastKnownContactPlaneCellId, value);
_lastKnownContactPlaneCellId = value;
}
}
public bool LastKnownContactPlaneIsWater
{
get => _lastKnownContactPlaneIsWater;
set
{
if (PhysicsDiagnostics.ProbeContactPlaneEnabled && _lastKnownContactPlaneIsWater != value)
PhysicsDiagnostics.LogCpBoolWrite("LastKnownContactPlaneIsWater", _lastKnownContactPlaneIsWater, value);
_lastKnownContactPlaneIsWater = value;
}
}
private static bool PlaneEquals(Plane a, Plane b) =>
a.Normal.X == b.Normal.X &&
a.Normal.Y == b.Normal.Y &&
a.Normal.Z == b.Normal.Z &&
a.D == b.D;
public bool SlidingNormalValid;
public Vector3 SlidingNormal; // XY only (Z zeroed)