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)