fix #137 (mechanism 2): BSP full-hit stubs leaked sliding normals — the corridor absorbing wedge
The Facility Hub corridor dead-stop's second half: after one seam hit, every forward resolve returned ok=False hit=no with zero advance. The body-persisted SlidingNormal (-1,0,0) projected the exactly-anti-parallel corridor push to zero in AdjustOffset and the step loop aborted at step 0 before any collision test could refresh the state. Audit (docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md): retail's only in-transition sliding-normal writer is validate_transition (0x0050ac21); the whole sphere/BSP layer writes NONE (grep-verified), and the body persistence (SetPositionInternal 0x005154c2, SLIDING_TS bit sync 0x005154e1) runs only on transition success. Our BSPQuery Contact-branch full-hit responses were stubs (SetCollisionNormal + SetSlidingNormal + return Slid) where retail dispatches the real slide_sphere — so the seam hit (a SUCCESSFUL full-advance resolve per the live log) persisted the phantom wall's normal, which retail's lifecycle structurally cannot do. - BSPQuery Contact foot full-hit fallback + head full-hit now route through Transition.SlideSphereInternal (CSphere::slide_sphere 0x00537440 — in-frame slide, no sliding-normal write; ACE BSPTree.cs:202,310-316). The dead stub is rewritten as the faithful BSPTREE::slide_sphere wrapper. - PhysicsEngine sliding writeback gated on ok (retail success-only placement; behaviorally latent, removes the failed-frame leak class). - Register: TS-4 amended (Path-6 steep-tangent sites still write the normal — now documented), TS-45 added (SphereCollision's write — same leak class, left for a follow-up out of #171's blast radius). - Pins: Issue137SlidingNormalLifecycleTests — both site pins RED->GREEN, plus the retail persist/absorb/clear wall lifecycle (validate-write persistence, faithful absorbed anti-parallel frame, oblique escape clears the bit). BSPQueryTests full-hit pin updated to the real slide. Mechanism 1 (PortalSide portal polys solid in the physics set) stays OPEN - #137 not closed; the corridor re-test rides that session. Suites: Core 2545 / App 713 / UI 425 / Net 385, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e73e45da54
commit
a11df5b8d3
7 changed files with 474 additions and 31 deletions
|
|
@ -0,0 +1,307 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's in-transition <c>collision_info.sliding_normal</c> has exactly ONE
|
||||
/// writer besides the per-frame seed: <c>CTransition::validate_transition</c>
|
||||
/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The
|
||||
/// BSP collision layer NEVER writes it — <c>BSPTREE::find_collisions</c>'
|
||||
/// Contact branch dispatches full hits to <c>step_sphere_up</c> (foot,
|
||||
/// 0x0053a719) / <c>BSPTREE::slide_sphere</c> (head, 0x0053a697), and
|
||||
/// <c>CSphere::slide_sphere</c> (0x00537440) slides IN-FRAME via
|
||||
/// <c>add_offset_to_check_pos</c> without touching sliding_normal
|
||||
/// (grep-verified: zero sliding_normal references between 0x005155 and
|
||||
/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only
|
||||
/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and
|
||||
/// Transition.cs:1027 (validate). The body-side persistence
|
||||
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x005154c2, SLIDING_TS bit-4 sync
|
||||
/// at 0x005154e1) runs only on transition SUCCESS.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// acdream's BSPQuery Contact branch carried stub fallbacks
|
||||
/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real
|
||||
/// slide. The leaked sliding normal survived to the transition end, the
|
||||
/// unconditional body writeback persisted it, and the next frame's seed
|
||||
/// projected an exactly-anti-parallel push to zero — aborting at step 0
|
||||
/// BEFORE any collision test could refresh the state. Live shape: the
|
||||
/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall
|
||||
/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no
|
||||
/// zero-advance resolves; strafe escapes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Issue137SlidingNormalLifecycleTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Site-level pins — BSPQuery.FindCollisions Contact branch must not write
|
||||
// the transition's sliding normal (retail: only validate_transition does).
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Contact foot-sphere FULL HIT with the step-up recursion unavailable
|
||||
/// (engine=null / step-up already in progress) must dispatch the real
|
||||
/// sphere slide — never the SetSlidingNormal stub.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail: a blocked step-up funnels to <c>SPHEREPATH::step_up_slide</c> →
|
||||
/// <c>CSphere::slide_sphere</c> (ACE SpherePath.cs:316 → Sphere.cs:558) —
|
||||
/// in-frame slide, no sliding_normal write. Face-on into a vertical wall
|
||||
/// while grounded: the crease projection (cross(wallN, floorN)) has no
|
||||
/// component along the movement, the slide offset is degenerate
|
||||
/// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
// Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5
|
||||
// (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall.
|
||||
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = to,
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
root, resolved, t, localSphere, null,
|
||||
from, Vector3.UnitZ, 1.0f);
|
||||
|
||||
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||
"find_collisions must not write collision_info.sliding_normal — " +
|
||||
"retail's only in-transition writer is validate_transition " +
|
||||
"(0x0050ac21). A sliding normal leaked here survives to the body " +
|
||||
"writeback and absorbs the next frame's forward offset (#137).");
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid,
|
||||
"The real slide records the collision normal (CSphere::slide_sphere " +
|
||||
"→ set_collision_normal).");
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact HEAD-sphere FULL HIT must dispatch <c>BSPTREE::slide_sphere</c>
|
||||
/// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real
|
||||
/// <c>Sphere.SlideSphere</c> on GlobalSphere[0]) — never the stub.
|
||||
/// The corridor phantom's portal-side polys span head height; this is the
|
||||
/// path that recorded the (−1,0,0) normal the wedge absorbed on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite()
|
||||
{
|
||||
// Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere
|
||||
// (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head
|
||||
// sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it.
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||
|
||||
var floorVerts = new[]
|
||||
{
|
||||
new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f),
|
||||
new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f),
|
||||
};
|
||||
resolved[1] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = floorVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, 0f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var wallNormal = new Vector3(-1f, 0f, 0f);
|
||||
var wallVerts = new[]
|
||||
{
|
||||
new Vector3(0.5f, -1f, 0.6f),
|
||||
new Vector3(0.5f, -1f, 5f),
|
||||
new Vector3(0.5f, 1f, 5f),
|
||||
new Vector3(0.5f, 1f, 0.6f),
|
||||
};
|
||||
resolved[2] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = wallVerts,
|
||||
Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var leaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f },
|
||||
};
|
||||
leaf.Polygons.Add(1);
|
||||
leaf.Polygons.Add(2);
|
||||
|
||||
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||
|
||||
var footSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = to,
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
var headSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = new Vector3(to.X, to.Y, 0.8f),
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
leaf, resolved, t, footSphere, headSphere,
|
||||
from, Vector3.UnitZ, 1.0f);
|
||||
|
||||
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||
"Head full hit must go through the real BSPTREE::slide_sphere — " +
|
||||
"no sliding_normal write at the BSP layer (retail 0x0053a697).");
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a
|
||||
// REAL wall. Guards the fix against regressing wall behavior, and
|
||||
// documents where retail CLEARS the body's sliding state (the successful
|
||||
// transition's writeback, when no step re-records a collision).
|
||||
// =========================================================================
|
||||
|
||||
private const uint CellId = 0xA9B40157u;
|
||||
|
||||
private static PhysicsEngine BuildWallEngine()
|
||||
{
|
||||
var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = wallRoot },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = wallResolved,
|
||||
CellBSP = new CellBSPTree
|
||||
{
|
||||
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||||
},
|
||||
};
|
||||
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
|
||||
// Flat terrain strip so the outdoor fall-through has something to
|
||||
// sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests).
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)0);
|
||||
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
engine.DataCache.RegisterCellStructForTest(CellId, cell);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static float[] BuildHeightTable()
|
||||
{
|
||||
var ht = new float[256];
|
||||
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
|
||||
return ht;
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 0f);
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
return body;
|
||||
}
|
||||
|
||||
private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body,
|
||||
Vector3 from, Vector3 to)
|
||||
=> engine.ResolveWithTransition(
|
||||
currentPos: from,
|
||||
targetPos: to,
|
||||
cellId: CellId,
|
||||
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||
sphereHeight: 0f, // single sphere — keeps the scenario deterministic
|
||||
stepUpHeight: 0.04f, // cannot scale the 5 m wall
|
||||
stepDownHeight: 0.04f,
|
||||
isOnGround: true,
|
||||
body: body);
|
||||
|
||||
/// <summary>
|
||||
/// The full retail lifecycle at a real wall:
|
||||
/// (1) a blocked face-on push persists the validate-recorded sliding
|
||||
/// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync,
|
||||
/// 0x005154e1);
|
||||
/// (2) the next exactly-anti-parallel push is absorbed by the seed
|
||||
/// (get_object_info 0x00511d44 → adjust_offset projects to zero →
|
||||
/// find_transitional_position's step-0 small-offset abort) — the
|
||||
/// retail cache semantics: "still pressed against this wall";
|
||||
/// (3) an oblique push escapes along the wall tangent, the step runs
|
||||
/// without re-recording a collision, and the successful writeback
|
||||
/// CLEARS the body's sliding state (sliding_normal_valid==0 → bit
|
||||
/// 4 cleared).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape()
|
||||
{
|
||||
var engine = BuildWallEngine();
|
||||
var body = GroundedBody();
|
||||
|
||||
// ── 1. Face-on +X into the wall at x=0.5 (normal −X) ─────────────
|
||||
var r1 = ResolveForward(engine, body,
|
||||
from: new Vector3(0.10f, 0f, 0f),
|
||||
to: new Vector3(0.35f, 0f, 0f));
|
||||
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A blocked push must persist the validate-recorded sliding normal " +
|
||||
"(retail SetPositionInternal 0x005154c2 on transition success).");
|
||||
Assert.True(body.SlidingNormal.X < -0.9f,
|
||||
$"Persisted normal should face the mover (−X); got {body.SlidingNormal}.");
|
||||
Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius
|
||||
<= 0.5f + PhysicsGlobals.EPSILON * 20f,
|
||||
$"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}.");
|
||||
|
||||
// ── 2. Exactly-anti-parallel push again: absorbed frame ──────────
|
||||
var r2 = ResolveForward(engine, body,
|
||||
from: r1.Position,
|
||||
to: r1.Position + new Vector3(0.15f, 0f, 0f));
|
||||
|
||||
Assert.False(r2.Ok,
|
||||
"The seeded sliding normal projects the exactly-anti-parallel " +
|
||||
"offset to zero → step-0 abort (retail find_transitional_position " +
|
||||
"0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall.");
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A failed transition leaves the body's sliding state untouched " +
|
||||
"(retail: SetPositionInternal never runs on failure).");
|
||||
|
||||
// ── 3. Oblique push escapes and CLEARS the persisted state ───────
|
||||
var r3 = ResolveForward(engine, body,
|
||||
from: r2.Position,
|
||||
to: r2.Position + new Vector3(0.10f, 0.15f, 0f));
|
||||
|
||||
Assert.True(r3.Ok, "Oblique push must escape along the wall tangent.");
|
||||
Assert.True(r3.Position.Y > r2.Position.Y + 0.05f,
|
||||
$"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " +
|
||||
$"(from {r2.Position.Y:F4}).");
|
||||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A successful transition whose steps re-record no collision must " +
|
||||
"CLEAR the body's sliding state (retail SetPositionInternal " +
|
||||
"0x005154e1: bit 4 synced from the transition's final " +
|
||||
"sliding_normal_valid, which each step clears before its insert).");
|
||||
Assert.Equal(Vector3.Zero, body.SlidingNormal);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue