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