The mechanism-1 theory (PortalSide portal polys solid in our physics set)
is REFUTED for the corridor repro, and the remaining half of the phantom
is fixed — no cdb session needed:
- The live hit normal (-1.00,0.03,-0.03) matches NO dat polygon: a
world-space sweep of both seam cells + every portal-adjacent neighbor
(CorridorSeam_FindPolygonMatchingLiveHit) returns zero candidates. The
normal is the negated movement direction — the SYNTHETIC value
slide_sphere's opposing-normals branch records (reversed = -gDelta).
- Cell 0x8A02016E has IDENTITY rotation (the prior session's 'rotation
maps the portal planes into the -X wall' was a misattribution). The
PortalSide polys to 0x011E are +-Y planes 1.4 m beside the player's
track, perpendicular to the +X run — pos_hits_sphere's directional
cull rejects them for that movement. They ARE referenced by the dat's
physics-BSP leaves (CorridorCell_PhysicsBspLeafMembership), so retail
tests them too when approached into their plane; the dat's
keep-PortalSide / strip-ExactMatch asymmetry reads as intentional
(solid window/grate-class portals). No portal-poly filter — exactly
the blanket-skip the pickup warned against.
- Port fix: CSphere::slide_sphere's opposing-normals branch
(0x005375d7-0x0053762c) records the reversed displacement and returns
COLLIDED_TS; our port returned OK ('retail returns OK here' was a
decomp misread), letting the step complete as-is with the synthetic
collision normal that validate's epilogue then persisted as the
sliding normal the wedge absorbed on. TransitionTypes opposing branch
now returns Collided; pinned by
SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal
(RED->GREEN).
- Dat-backed replay (Issue137CorridorSeamReplayTests) reproduces the
live hit frame verbatim (same in/out to the millimeter, same 016E->017A
transit, same +8mm settle) and runs the corridor CLEAN: hit=no, no
sliding normal persisted, six further forward frames advance freely.
- Inspection tests extended: physics-BSP leaf membership walk +
hit-normal candidate sweep + downward-poly sweep (all report-style,
dat-gated). Pickup prompt banner'd SUPERSEDED; ISSUES #137 updated
(door half stays open); audit doc extended with the resolution.
Suites: Core 2551 / App 713 / UI 425 / Net 385, 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
343 lines
16 KiB
C#
343 lines
16 KiB
C#
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>CSphere::slide_sphere</c>'s opposing-normals branch (collision
|
||
/// normal anti-parallel to the contact plane — e.g. a ceiling-facing
|
||
/// normal while grounded) records the REVERSED displacement as the
|
||
/// collision normal and returns <b>COLLIDED_TS</b> — retail 0x00537440
|
||
/// @0x005375d7-0x0053762c: <c>*normal = −gDelta; normalize;
|
||
/// set_collision_normal; return 2</c>. Our port returned OK (its comment
|
||
/// even claimed "retail returns OK here"), letting the step complete
|
||
/// as-is with a synthetic reversed-movement collision normal — the exact
|
||
/// signature of the live corridor hit (`hit=yes n=(−1.00,0.03,−0.03)` =
|
||
/// the negated run direction, matching NO dat polygon).
|
||
/// </summary>
|
||
[Fact]
|
||
public void SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal()
|
||
{
|
||
var t = new Transition();
|
||
t.SpherePath.InitPath(
|
||
new Vector3(0f, 0f, 0.2f), new Vector3(0.3f, 0f, 0.2f),
|
||
0xA9B40001u, BSPStepUpFixtures.SphereRadius);
|
||
t.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), 0xA9B40001u, false);
|
||
|
||
// Make gDelta exactly (0.4, 0, 0): currPos = check sphere − (0.4,0,0).
|
||
var currPos = t.SpherePath.GlobalSphere[0].Origin - new Vector3(0.4f, 0f, 0f);
|
||
|
||
// Downward collision normal vs the +Z contact plane → cross ≈ 0
|
||
// (parallel), dot = −1 < 0 (opposing) → the reverse branch.
|
||
var result = t.SlideSphereInternal(new Vector3(0f, 0f, -1f), currPos);
|
||
|
||
Assert.Equal(TransitionState.Collided, result);
|
||
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||
Assert.True(t.CollisionInfo.CollisionNormal.X < -0.99f,
|
||
$"Collision normal must be the normalized reversed displacement " +
|
||
$"(−1,0,0); got ({t.CollisionInfo.CollisionNormal.X:F3}," +
|
||
$"{t.CollisionInfo.CollisionNormal.Y:F3},{t.CollisionInfo.CollisionNormal.Z:F3}).");
|
||
}
|
||
|
||
// =========================================================================
|
||
// 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);
|
||
}
|
||
}
|