diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 05935e8..897c35d 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1,4 +1,5 @@ using System; +using System.Numerics; namespace AcDream.Core.Physics; @@ -221,4 +222,70 @@ public static class PhysicsDiagnostics /// public static bool ProbeCellCacheEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1"; + + /// + /// ContactPlane retention spike (2026-05-20). When true, every write to + /// CollisionInfo.ContactPlane{,Valid,CellId,IsWater} and + /// LastKnownContactPlane{,Valid,CellId,IsWater} emits one + /// [cp-write] 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 + /// FindEnvCollisions indoor branch is rewriting CP every frame + /// instead of retaining it across frames. + /// + /// + /// Only logs when the value actually changes (suppresses no-op writes to + /// reduce log volume). Initial state from + /// ACDREAM_PROBE_CONTACT_PLANE=1. Spike-only — remove once the fix + /// lands and the diagnostic value is captured. + /// + /// + 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}")); + } + + /// + /// Walks the stack to identify the first frame outside CollisionInfo + /// and PhysicsDiagnostics — that's the actual caller writing the + /// ContactPlane field. Format: TypeName.MethodName:line when file + /// info is available, else just TypeName.MethodName. Walked with + /// fileNeeded=true only when the probe flag is on, so zero cost + /// when off. + /// + 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 "?"; + } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index ae2b3de..18bab97 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -125,15 +125,115 @@ public sealed class ObjectInfo /// 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)