diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 4668b1f1..c0fd0cea 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -1927,6 +1927,26 @@ public sealed class PhysicsEngine
{
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
+
+ // #338 (TEMPORARY): the resolver's own reading, taken where the
+ // values actually land rather than at one of two candidate call
+ // sites. The first attempt probed PlayerMovementController and
+ // printed NOTHING across 11,523 live log lines — the wrong one of
+ // its two resolve calls. A silent probe proves nothing, so this
+ // one sits where every caller must pass through. Filtered to the
+ // player so remotes cannot drown it.
+ // The flag test MUST precede the interpolated string: this site runs
+ // per resolve, and building the detail eagerly cost 128 B/resolve
+ // with the probe OFF — caught by Slice I1's zero-allocation gate,
+ // which is exactly what that gate is for.
+ if (PhysicsDiagnostics.ProbeStepHeightsEnabled
+ && (moverFlags & ObjectInfoState.IsPlayer) != 0)
+ {
+ PhysicsDiagnostics.LogStepHeights(
+ "resolve", stepUpHeight, stepDownHeight,
+ $"onGround={isOnGround} hasBody={body is not null}");
+ }
+
transition.ObjectInfo.StepDown = true;
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
// skipped in FindObjCollisions or the sweep collides with self.
@@ -1993,7 +2013,15 @@ public sealed class PhysicsEngine
transition.ObjectInfo.State |= ObjectInfoState.Contact;
if (body.OnWalkable)
transition.ObjectInfo.State |= ObjectInfoState.OnWalkable;
- transition.CollisionInfo.SetContactPlane(
+ // #32 (2026-08-07): InitContactPlane, not SetContactPlane.
+ // This is retail's check_contact SUCCESS branch — the
+ // start-of-transition seed, where both groups are meant to
+ // be written because there is no earlier surface to
+ // remember. Every OTHER call site keeps the narrowed
+ // setter, so a steep face met mid-transition can no longer
+ // overwrite the walkable surface cliff_slide needs as its
+ // second cross-product vector.
+ transition.CollisionInfo.InitContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 556998c4..97865f43 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -497,6 +497,53 @@ public sealed class CollisionInfo
ContactPlaneCellId = cellId;
ContactPlaneIsWater = isWater;
+ // #32 (2026-08-07): the four last-known writes that used to sit here
+ // are DELETED. Retail's COLLISIONINFO::set_contact_plane @0x00509d80
+ // is 22 bytes and writes the CONTACT group only; the last-known group
+ // has exactly four writers, none of them this one.
+ //
+ // Why it mattered: CTransition::cliff_slide @0x0050a6d0 takes its
+ // slide direction from cross(steep contact normal,
+ // last_known_contact_plane.N) — it NEEDS the surface the mover was
+ // standing on as the second vector. Latching last-known here meant
+ // that by the time cliff_slide ran, last-known had already been
+ // overwritten with the steep face itself, so the cross product was a
+ // vector with itself: zero. Measured live at Rithwic 2026-08-06,
+ // 6 events, every one curN == lastN == (-0.954, 0.000, 0.301),
+ // angle=0.0000, apply=False, and the player ran off the cliff.
+ // Capture + decision table:
+ // docs/research/2026-08-06-32-local-edge-slide-research.md §6.
+ //
+ // Callers that legitimately want BOTH groups written — retail's
+ // check_contact success path — call InitContactPlane instead.
+ }
+
+ ///
+ /// Retail CTransition::init_contact_plane @0x0050e850 — write the
+ /// contact group AND seed the last-known group from it.
+ ///
+ ///
+ /// This is the split half of #32's fix. Retail has two distinct
+ /// operations and acdream had collapsed them into one: an ordinary
+ /// mid-transition contact assertion (, which
+ /// must NOT touch last-known, or the rim-slide direction degenerates) and
+ /// the start-of-transition seed, which establishes both because there is
+ /// no earlier surface to remember.
+ ///
+ ///
+ ///
+ /// Only check_contact's SUCCESS branch calls this. Its failure
+ /// branch already mirrors retail's init_last_known_contact_plane
+ /// and is deliberately left alone.
+ ///
+ ///
+ public void InitContactPlane(
+ Plane plane,
+ uint cellId,
+ bool isWater = false)
+ {
+ SetContactPlane(plane, cellId, isWater);
+
LastKnownContactPlaneValid = true;
LastKnownContactPlane = plane;
LastKnownContactPlaneCellId = cellId;
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index c013fe9b..d8968b89 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -2625,15 +2625,6 @@ public sealed class PlayerMovementController
// or it re-zeros the gravity velocity and the body re-wedges instead of falling off.
bool candidateMoved = postIntegratePos != preIntegratePos;
- // #338 (TEMPORARY): third and last reading along the chain — what
- // the resolver is ACTUALLY handed, per ordinary movement tick.
- // Edge-triggered inside the probe, so this per-tick site prints
- // once per distinct pair and cannot drown the two one-shot sites
- // it exists to be compared against.
- PhysicsDiagnostics.LogStepHeights(
- "resolve", StepUpHeight, StepDownHeight,
- $"onWalkable={_body.OnWalkable} cell=0x{CellId:X8}");
-
// ── 3. Collision resolution via CTransition sphere-sweep ─────────────
// The Transition system subdivides the movement from pre→post into
// sphere-radius steps, testing terrain collision at each step.
diff --git a/tests/AcDream.Core.Tests/Physics/Issue32LastKnownContactPlaneTests.cs b/tests/AcDream.Core.Tests/Physics/Issue32LastKnownContactPlaneTests.cs
new file mode 100644
index 00000000..026d88c2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Issue32LastKnownContactPlaneTests.cs
@@ -0,0 +1,140 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// #32 regression — the last-known contact plane must survive a steep
+/// mid-transition contact.
+///
+///
+/// CTransition::cliff_slide @0x0050a6d0 takes its slide direction from
+/// cross(steep contact normal, last_known_contact_plane.N). It needs
+/// the surface the mover was STANDING ON as the second vector. acdream's
+/// CollisionInfo.SetContactPlane used to latch the last-known group on
+/// every call, so by the time cliff_slide ran, last-known had already
+/// been overwritten with the steep face itself — the cross product of a vector
+/// with itself, which is zero. Degenerate direction, no slide applied, and the
+/// player walked off the cliff.
+///
+///
+///
+/// Retail's COLLISIONINFO::set_contact_plane @0x00509d80 is 22 bytes
+/// and writes the CONTACT group only. The last-known group has four writers,
+/// none of them that function.
+///
+///
+///
+/// Measured live at Rithwic on 2026-08-06 with
+/// ACDREAM_DUMP_EDGE_SLIDE=1: six branch2/steep-cliffslide
+/// events, every one reporting
+/// curN=(-0.954,0.000,0.301) lastN=(-0.954,0.000,0.301) angle=0.0000
+/// apply=False with outcome degenerate-cross/last-known — the two
+/// vectors identical to three decimals. That capture is decision-table row 1
+/// of docs/research/2026-08-06-32-local-edge-slide-research.md, and the
+/// normals below are taken from it verbatim rather than invented.
+///
+///
+///
+/// Sabotage check. Restore the four last-known writes at the tail of
+/// SetContactPlane and
+///
+/// fails on its lastN assertion while
+/// keeps passing — the pair
+/// separates "the latch is gone" from "nothing writes last-known at all",
+/// which a single test could not do.
+///
+///
+public sealed class Issue32LastKnownContactPlaneTests
+{
+ private const uint Cell = 0x0001_0001u;
+
+ /// The flat ground the mover is standing on before the rim.
+ private static readonly Plane Walkable = new(Vector3.UnitZ, 0f);
+
+ ///
+ /// The Rithwic cliff face, verbatim from the live capture: a normal whose
+ /// Z of 0.301 is about 72.5° from horizontal, far below
+ /// , so it is rejected as walkable and
+ /// routes to the cliff-slide branch.
+ ///
+ private static readonly Plane Steep =
+ new(Vector3.Normalize(new Vector3(-0.954f, 0f, 0.301f)), 0f);
+
+ [Fact]
+ public void MidTransitionSteepContact_LeavesTheWalkableLastKnownPlaneIntact()
+ {
+ var ci = new CollisionInfo();
+
+ // Start of transition: standing on flat ground. Retail's
+ // init_contact_plane seeds BOTH groups here, because there is no
+ // earlier surface to remember.
+ ci.InitContactPlane(Walkable, Cell);
+
+ // Mid-transition: the sweep meets the cliff face. Retail's
+ // set_contact_plane writes the contact group and nothing else.
+ ci.SetContactPlane(Steep, Cell);
+
+ Assert.True(ci.ContactPlaneValid);
+ Assert.Equal(Steep.Normal, ci.ContactPlane.Normal);
+
+ Assert.True(
+ ci.LastKnownContactPlaneValid,
+ "the seeded last-known plane must survive a steep contact — "
+ + "cliff_slide has no second vector without it.");
+ Assert.Equal(Walkable.Normal, ci.LastKnownContactPlane.Normal);
+
+ // The consequence that actually mattered in play: a non-degenerate
+ // slide direction. With the latch in place both vectors were the steep
+ // face and this cross product was the zero vector.
+ Vector3 slide = Vector3.Cross(Steep.Normal, ci.LastKnownContactPlane.Normal);
+ Assert.True(
+ slide.Length() > 1e-4f,
+ $"cliff_slide's direction is degenerate: |cross| = {slide.Length():E3}. "
+ + $"contactN = {Steep.Normal}, lastN = {ci.LastKnownContactPlane.Normal}.");
+ }
+
+ ///
+ /// CONTROL. The seed path must still write both groups, or the test above
+ /// would pass simply because nothing ever populates last-known.
+ ///
+ [Fact]
+ public void InitContactPlane_SeedsBothGroups()
+ {
+ var ci = new CollisionInfo();
+
+ Assert.False(ci.LastKnownContactPlaneValid);
+
+ ci.InitContactPlane(Walkable, Cell, isWater: true);
+
+ Assert.True(ci.ContactPlaneValid);
+ Assert.Equal(Walkable.Normal, ci.ContactPlane.Normal);
+ Assert.Equal(Cell, ci.ContactPlaneCellId);
+ Assert.True(ci.ContactPlaneIsWater);
+
+ Assert.True(ci.LastKnownContactPlaneValid);
+ Assert.Equal(Walkable.Normal, ci.LastKnownContactPlane.Normal);
+ Assert.Equal(Cell, ci.LastKnownContactPlaneCellId);
+ Assert.True(ci.LastKnownContactPlaneIsWater);
+ }
+
+ ///
+ /// A second steep contact must not resurrect the defect through the
+ /// no-op-if-unchanged guard: the guard returns early when the CONTACT
+ /// group already matches, and an early return must not be the only thing
+ /// protecting last-known.
+ ///
+ [Fact]
+ public void RepeatedSteepContact_StillLeavesLastKnownIntact()
+ {
+ var ci = new CollisionInfo();
+ ci.InitContactPlane(Walkable, Cell);
+
+ ci.SetContactPlane(Steep, Cell);
+ ci.SetContactPlane(Steep, Cell); // hits the no-op guard
+ ci.SetContactPlane(Steep, Cell);
+
+ Assert.Equal(Walkable.Normal, ci.LastKnownContactPlane.Normal);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
index 7b5f3672..a1a5fd41 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
@@ -739,7 +739,17 @@ public sealed class PhysicsSetPositionTests
if (phase == TransitionCellCollisionPhase.Environment)
{
transition.CollisionInfo.CollidedWithEnvironment = true;
- transition.CollisionInfo.SetContactPlane(
+ // #32 (2026-08-07): InitContactPlane, not SetContactPlane.
+ // This test asserts that the REPORT carries every field,
+ // including the last-known group — it is not a claim about
+ // which setter latches what. It used to pass because
+ // SetContactPlane seeded last-known as a side effect; that
+ // side effect was the #32 defect and is now deleted, so the
+ // fixture populates both groups explicitly instead of
+ // depending on it. Retail's own start-of-transition seed
+ // (CTransition::init_contact_plane @0x0050e850) is exactly
+ // this call.
+ transition.CollisionInfo.InitContactPlane(
new Plane(Vector3.UnitZ, -3f),
Cell,
isWater: true);
diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
index 89946344..eb932232 100644
--- a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
@@ -206,9 +206,22 @@ public sealed class RetailEdgeResponseOrderingTests
if (candidate.SpherePath.StepDown)
{
// The nested downward probe finds a steep contact. It is
- // rejected as walkable, and because SetContactPlane also
- // latches the same last-known plane, CliffSlide's cross is
- // parallel/degenerate and writes OK_TS with stop=false.
+ // rejected as walkable, and CliffSlide takes its degenerate
+ // OK_TS return with stop=false.
+ //
+ // #32 (2026-08-07) — WHY THIS STILL PASSES, because the
+ // reason CHANGED and the old comment here was describing
+ // deleted behaviour. It used to read "because
+ // SetContactPlane also latches the same last-known plane,
+ // CliffSlide's cross is parallel/degenerate". That latch is
+ // gone. This fixture never seeds a last-known plane, so
+ // last-known is now simply ABSENT, and CliffSlide's
+ // degenerate return covers that case too — retail genuinely
+ // returns OK_TS when last-known is missing (see
+ // CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn).
+ // Same outcome, different cause. This test therefore does
+ // NOT discriminate the #32 fix; Issue32LastKnownContactPlaneTests
+ // does, and is sabotage-verified.
candidate.CollisionInfo.SetContactPlane(steep, Cell);
}
else