fix(physics): split set_contact_plane from init_contact_plane (#32 local edge-slide)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Measured live at Rithwic 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, outcome degenerate-cross/last-known. That is decision-table
row 1 of the research doc, verbatim.

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 latched 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, walk 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. So the four writes are DELETED and a new
InitContactPlane mirrors CTransition::init_contact_plane @0x0050e850,
writing both — the start-of-transition seed, where there is no earlier
surface to remember. Only check_contact's SUCCESS branch calls it. The
other eleven call sites keep the narrowed setter. This is a port, not a
suppression: no guard, no grace period, no flag.

The user's own A/B was the discriminator: Neftet's block plateaus hold
(188 branch3/precipice-slide events, all before the teleport) while
Rithwic's terrain cliff fails (6 branch2 events, all after). I had
predicted the opposite — that terrain would be the flat-normal case —
and position plus timeline corrected me, not reasoning.

NEW DISCRIMINATING TEST, because the suite had none. It was green both
before and after the production change, so nothing in it defended this
behaviour. Issue32LastKnownContactPlaneTests seeds a walkable plane,
asserts a steep mid-transition contact leaves it intact, and asserts the
resulting cross product is non-degenerate. Sabotage-verified: restore
the four writes and both discriminating rows fail while the
InitContactPlane control keeps passing — the pair separates 'the latch
is gone' from 'nothing writes last-known at all'.

Two existing tests corrected rather than deleted.
PhysicsSetPositionTests.FailedCheck_MapsCollisionHandlerResultToRetailError
passed BECAUSE of the latch (the file the research named); its hook now
populates both groups explicitly, since it asserts report plumbing, not
setter semantics. RetailEdgeResponseOrderingTests.TransitionalInsert_
DegenerateCliffSlideOk_ContinuesOuterRetry was predicted to fail and did
not — it now passes for a DIFFERENT reason (last-known absent rather
than clobbered, which retail also answers with OK_TS). Its comment
described the deleted behaviour and is corrected to say so, and to say
it does not discriminate this fix.

Also repairs the #338 probe. Its first placement in
PlayerMovementController printed nothing across 11,523 live log lines —
the wrong one of two resolve call sites — so it moves to
PhysicsEngine.ResolveWithTransition where every caller passes through,
filtered to the player. The dead site is removed rather than left in
place; a probe that never fires is worse than none. The flag test now
precedes the interpolated string: building it eagerly cost 128 B per
resolve with the probe OFF, which Slice I1's zero-allocation gate caught.

Suite 11,234 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 00:15:24 +02:00
parent 45d7154712
commit 332045c7ad
6 changed files with 243 additions and 14 deletions

View file

@ -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);

View file

@ -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.
}
/// <summary>
/// Retail <c>CTransition::init_contact_plane</c> @0x0050e850 — write the
/// contact group AND seed the last-known group from it.
///
/// <para>
/// 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 (<see cref="SetContactPlane"/>, 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.
/// </para>
///
/// <para>
/// Only <c>check_contact</c>'s SUCCESS branch calls this. Its failure
/// branch already mirrors retail's <c>init_last_known_contact_plane</c>
/// and is deliberately left alone.
/// </para>
/// </summary>
public void InitContactPlane(
Plane plane,
uint cellId,
bool isWater = false)
{
SetContactPlane(plane, cellId, isWater);
LastKnownContactPlaneValid = true;
LastKnownContactPlane = plane;
LastKnownContactPlaneCellId = cellId;

View file

@ -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.

View file

@ -0,0 +1,140 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #32 regression — the last-known contact plane must survive a steep
/// mid-transition contact.
///
/// <para>
/// <c>CTransition::cliff_slide</c> @0x0050a6d0 takes its slide direction from
/// <c>cross(steep contact normal, last_known_contact_plane.N)</c>. It needs
/// the surface the mover was STANDING ON as the second vector. acdream's
/// <c>CollisionInfo.SetContactPlane</c> used to latch the last-known group on
/// every call, so by the time <c>cliff_slide</c> 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.
/// </para>
///
/// <para>
/// Retail's <c>COLLISIONINFO::set_contact_plane</c> @0x00509d80 is 22 bytes
/// and writes the CONTACT group only. The last-known group has four writers,
/// none of them that function.
/// </para>
///
/// <para>
/// <b>Measured live at Rithwic on 2026-08-06</b> with
/// <c>ACDREAM_DUMP_EDGE_SLIDE=1</c>: six <c>branch2/steep-cliffslide</c>
/// events, every one reporting
/// <c>curN=(-0.954,0.000,0.301) lastN=(-0.954,0.000,0.301) angle=0.0000
/// apply=False</c> with outcome <c>degenerate-cross/last-known</c> — the two
/// vectors identical to three decimals. That capture is decision-table row 1
/// of <c>docs/research/2026-08-06-32-local-edge-slide-research.md</c>, and the
/// normals below are taken from it verbatim rather than invented.
/// </para>
///
/// <para>
/// <b>Sabotage check.</b> Restore the four last-known writes at the tail of
/// <c>SetContactPlane</c> and
/// <see cref="MidTransitionSteepContact_LeavesTheWalkableLastKnownPlaneIntact"/>
/// fails on its <c>lastN</c> assertion while
/// <see cref="InitContactPlane_SeedsBothGroups"/> keeps passing — the pair
/// separates "the latch is gone" from "nothing writes last-known at all",
/// which a single test could not do.
/// </para>
/// </summary>
public sealed class Issue32LastKnownContactPlaneTests
{
private const uint Cell = 0x0001_0001u;
/// <summary>The flat ground the mover is standing on before the rim.</summary>
private static readonly Plane Walkable = new(Vector3.UnitZ, 0f);
/// <summary>
/// The Rithwic cliff face, verbatim from the live capture: a normal whose
/// Z of 0.301 is about 72.5° from horizontal, far below
/// <see cref="PhysicsGlobals.FloorZ"/>, so it is rejected as walkable and
/// routes to the cliff-slide branch.
/// </summary>
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}.");
}
/// <summary>
/// CONTROL. The seed path must still write both groups, or the test above
/// would pass simply because nothing ever populates last-known.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}

View file

@ -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);

View file

@ -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