acdream/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs
Erik e8651b3819 fix #137 (corridor phantom resolved): slide_sphere opposing branch returns Collided; the 'wall' was synthetic
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>
2026-07-05 18:27:40 +02:00

343 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/// (&lt; 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);
}
}