fix(physics): #116 shape-1 — Path-6 head-sphere direct Collided return

Per docs/research/2026-07-30-ts4-116-oracle-plan.md §2.3-§2.4: retail's
airborne (not-yet-Contact) BSPTREE::find_collisions dispatch, when the
FOOT sphere is completely clear but the HEAD sphere hits or near-misses,
does not defer through SetCollide/Adjusted (nor the steep-poly
slide-tangent shortcut) — it records the head polygon's normal directly
and hard-stops: pc:323824-323834 (0x0053a793/0x0053a7a4), independently
cross-checked against ACE BSPTree.cs:221-230 (`SetCollisionNormal` +
`return TransitionState.Collided;`), an exact structural match confirming
this isn't a BN misdecompile. BSPQuery.cs's Path 6 `hasSphere1` branch now
does the same: `collisions.SetCollisionNormal(worldNormal1); return
TransitionState.Collided;`, replacing the old steep-shortcut-or-deferred-
SetCollide handling. This mechanically retires one of TS-4's two
`SetSlidingNormal` write sites (sphere1's) ahead of TS-4's own item.

Added two permanent diagnostics gated on the existing
PhysicsDiagnostics.ProbeIndoorBspEnabled flag (`[path-dispatch]` at
FindCollisionsCore entry, `[path5-diag]` inside Path 5) to make future
BSPQuery dispatch tracing cheaper.

HONEST RESULT of the plan's own confirming instrumentation (re-run of
DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals):
this fix does NOT change the tick-22760 outcome (harness still cn=(0,0,1)
vs live cn=(0,+1,0)). The new dispatch-entry probes show the tick-22760
mover is GROUNDED (Contact set), so it never reaches Path 6 at all — it
dispatches Path 5 -> StepSphereDown (Path 3, both DoStepDown half-steps
fail) -> EdgeSlideAfterStepDownFailed -> SpherePath.PrecipiceSlide, whose
find_crossed_edge-false fallback returns Collided with NO collision-normal
write. A fresh byte-level read of retail's SPHEREPATH::precipice_slide
(pc:274316-274326, 0x0050cc80) confirms this is byte-exact retail
behavior (`if (eax == 0) { walkable = 0; return 2; }`, no
set_collision_normal call) — not a bug. The real tick-22760 divergence is
further upstream, most likely this test's simplified door registration
(BuildEngineWithDoorFixture) not placing the door's BSP where live retail
actually intersected it, or a walkable-polygon state-capture gap — see
the research doc's Addendum 2 for the full trace and open candidates.

#116 shape-1 is therefore NARROWED, not closed: the Path-6 fix is a real,
independent retail-faithfulness improvement; the tick-22760 acceptance
criterion is not met by it and needs further harness/geometry work before
any further code change.

Full AcDream.Core.Tests suite: 4059 passed / 2 skipped, no regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 12:36:26 +02:00
parent 7e1be3def0
commit db2889afda
2 changed files with 158 additions and 22 deletions

View file

@ -640,3 +640,102 @@ within 10° of flat; the shipped ACE-derived constant engages only within
the TS-4/#116 implementation slice, which owns `PhysicsBody`): replace the TS-4/#116 implementation slice, which owns `PhysicsBody`): replace
`0.99999536f` with `0.98480775f` (cos 10°), cite this addendum, retire `0.99999536f` with `0.98480775f` (cos 10°), cite this addendum, retire
register row AD-55 in the same commit. register row AD-55 in the same commit.
## Addendum 2 (implementation session, 2026-07-30): #116 shape-1's tick-22760
## confirming run DISAGREES with this plan's hypothesis — the real mechanism
## is one layer further upstream, and it's retail-faithful there too
Per this doc's own §4 execution order, item 1 (the `TransitionalInsert`
exhausted-loop hardcoded return) landed first — mechanical, zero
observable behavior change, confirmed by the full `AcDream.Core.Tests`
suite (4059 passed / 2 skipped, no change in pass count). Then §2.3's
shape-1 fix landed verbatim in `BSPQuery.cs`'s Path 6 `hasSphere1`
branch: a foot-clear/head-hit airborne contact now returns
`TransitionState.Collided` with a direct `collisions.SetCollisionNormal`
write, exactly matching pc:323824-323834 (`0x0053a793`/`0x0053a7a4`) and
ACE `BSPTree.cs:221-230`. This is a real, independently-decomp-confirmed
port-accuracy fix and is kept regardless of the result below.
**The confirming instrumentation run (§2.4) DISAGREES with the plan's
tick-22760 hypothesis.** Re-running
`DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals`
after the fix landed shows **no change**: harness still reports
`cn=(0,0,1)` (the `UnitZ` ground-fallback default) against live's
`cn=(0,+1,0)` (the door-face normal). Adding a dispatcher-entry probe
(`[path-dispatch]`, `[path5-diag]`, gated on the existing
`ProbeIndoorBspEnabled` flag, kept in `BSPQuery.cs` as permanent
diagnostics) traced the ACTUAL call sequence for this capture:
1. The seeded body's `TransientState` (131 = `Contact | OnWalkable |
Active`) means `ObjectInfo.State & Contact != 0` for this mover — it
is **grounded**, so `BSPQuery.FindCollisionsCore` dispatches to
**Path 5** (the Contact/grounded branch), never Path 6 at all. The
plan's shape-1 hypothesis was explicitly scoped to "the not-yet-in-
Contact branch" (§2.3) — that scoping was itself the unconfirmed
part, and it does not hold for tick-22760.
2. Path 5's own dispatch for the door's BSP shape at this exact position
finds **neither sphere hitting nor near-missing**
(`hit0=False hitPoly0=False hit1=False hitPoly1=False`) — the
simplified fixture registration this test uses
(`BuildEngineWithDoorFixture`, which places the raw GfxObj BSP
directly at its captured world-space bounding-sphere center rather
than via the faithful `ShadowShapeBuilder.FromSetup` +
`PlacementFrame` transform that `BuildFaithfulDoorEngine` uses
elsewhere in the same file) returns `OK` for the door here.
3. `TransitionalInsert`'s step-down gate then fires
(`contactInvalidOrSteep` is true because the per-substep walk loop
clears `ContactPlaneValid` before every `TransitionalInsert` call —
`TransitionTypes.cs` around the `FindValidPosition` per-step reset —
so the door BSP is queried TWICE MORE via `DoStepDown`'s two half-
height attempts, dispatching to **Path 3** (`StepSphereDown`
`FindWalkableInternal`), which also finds no walkable candidate here
(the door face is not a floor-like polygon) and returns `OK` both
times.
4. Both `DoStepDown` calls therefore fail (return `false`), which routs
into `EdgeSlideAfterStepDownFailed`. With `ContactPlaneValid` false,
`OnWalkable` true (seeded), `EdgeSlide` true (mover flags), and the
RESTORED walkable polygon from the body's own snapshot (a flat
triangle `(144,0,94)-(144,24,94)-(120,24,94)`, `Normal.Z=1 >=
FloorZ`), execution reaches `sp.PrecipiceSlide(this)`
(`TransitionTypes.cs` "branch3/precipice-slide").
5. **`SpherePath.PrecipiceSlide` calls `BSPQuery.FindCrossedEdge` against
that seeded triangle. The player's actual sweep (X≈133, Y from 18.02
to 17.60) does not cross ANY of that triangle's three edges** (the
triangle spans roughly X∈[120,144], and its hypotenuse sits at
X+Y=144 — at X=133 that's Y≈11, far south of the player's Y range).
`FindCrossedEdge` returns false, and acdream's `PrecipiceSlide`
(`TransitionTypes.cs:1039-1054`) does exactly what retail's
`SPHEREPATH::precipice_slide` does on the identical branch — **read
fresh this session, pc:274316-274326, `0x0050cc80`**:
```
int32_t eax = CPolygon::find_crossed_edge(...);
if (eax == 0) { this->walkable = eax; return 2; /* COLLIDED_TS */ }
```
**No `set_collision_normal` call on this path in retail either.**
This is a byte-exact match, not an inference — acdream's
`ClearWalkable(); return TransitionState.Collided;` on a failed
`FindCrossedEdge` is retail-faithful. `ValidateTransition`'s
`UnitZ`-default-on-invalid-normal fires identically in both engines
for this exact mechanism.
**Conclusion: the tick-22760 divergence is NOT explained by anything this
plan identified, and the mechanism this pass traced down to (Path 5 →
StepSphereDown → EdgeSlideAfterStepDownFailed → PrecipiceSlide's
no-crossed-edge fallback) is independently confirmed retail-faithful at
every step, including a fresh byte-level read of `precipice_slide`
itself.** The remaining candidates, none guessed at here: (a) this
specific harness (`BuildEngineWithDoorFixture`) may simply not place the
door's BSP polygons where live retail's did at that exact tick — a
harness/fixture-geometry gap, not a response-layer code bug — worth
re-running this same capture through `BuildFaithfulDoorEngine`'s
Setup-based registration to check whether a REAL BSP hit against the
door (rather than the seeded generic floor triangle) changes the
outcome; (b) the seeded `WalkableVertices` triangle itself may not match
what retail's own walkable-polygon bookkeeping held at that instant
(a state-capture gap in the original 2026-05-24 live-capture tooling,
not necessarily an engine bug); (c) a genuinely different upstream
mechanism not yet traced. Per CLAUDE.md's no-guessing rule, none of
these is adopted without further evidence — #116 shape-1 stays
**narrowed, not closed**: the Path-6 fix is a real, independent
retail-faithfulness improvement, and the original tick-22760 acceptance
criterion is NOT met by it. See ISSUES.md #116 for the updated status.

View file

@ -1854,6 +1854,17 @@ public static class BSPQuery
var movement = sphere0.Center - localCurrCenter; var movement = sphere0.Center - localCurrCenter;
// #116 shape-1 instrumentation (Campaign P final physics slice,
// 2026-07-30; docs/research/2026-07-30-ts4-116-oracle-plan.md §2.4):
// dispatcher-entry probe naming which of Path 1-6 this call will
// take, so the tick-22760 replay's per-call path sequence can be
// read off directly instead of inferred from later probes.
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[path-dispatch] insertType={path.InsertType} obstructionEthereal={path.ObstructionEthereal} checkWalkable={path.CheckWalkable} stepDown={path.StepDown} collide={path.Collide} contact={((obj.State & ObjectInfoState.Contact) != 0)} hasSphere1={hasSphere1}"));
}
// A6.P1: snapshot dispatcher entry for the [push-back-disp] probe. // A6.P1: snapshot dispatcher entry for the [push-back-disp] probe.
// Emitted before path selection so the captured state reflects // Emitted before path selection so the captured state reflects
// the inputs the dispatcher routes on. The returnState=-1 sentinel // the inputs the dispatcher routes on. The returnState=-1 sentinel
@ -2039,6 +2050,19 @@ public static class BSPQuery
bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement, bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
ref hitPoly0, ref contact0); ref hitPoly0, ref contact0);
// #116 shape-1 instrumentation (Campaign P final physics slice,
// 2026-07-30; docs/research/2026-07-30-ts4-116-oracle-plan.md
// §2.4): the tick-22760 divergence turned out to be a GROUNDED
// mover (Contact set), so it dispatches here (Path 5), not the
// not-yet-Contact Path 6 the oracle plan's shape-1 hypothesis
// targeted. This probe pins exactly which of the four Path-5
// sub-branches fires for the failing capture.
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[path5-diag] hit0={hit0} hitPoly0={(hitPoly0 is not null)} hasSphere1={hasSphere1}"));
}
// A6.P4 door inside-out fix (2026-05-25). Retail distinguishes // A6.P4 door inside-out fix (2026-05-25). Retail distinguishes
// FULL HIT (eax_10 != 0 → step_sphere_up early-return) from // FULL HIT (eax_10 != 0 → step_sphere_up early-return) from
// NEAR-MISS (eax_10 == 0 but var_5c != 0 → set_neg_poly_hit). // NEAR-MISS (eax_10 == 0 but var_5c != 0 → set_neg_poly_hit).
@ -2107,6 +2131,14 @@ public static class BSPQuery
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement, bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1); ref hitPoly1, ref contact1);
// #116 shape-1 instrumentation (see the hit0/hitPoly0 probe
// above for the full citation).
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[path5-diag] hit1={hit1} hitPoly1={(hitPoly1 is not null)}"));
}
if (hit1) if (hit1)
{ {
// Sphere 1 (head) full hit → slide_sphere. // Sphere 1 (head) full hit → slide_sphere.
@ -2234,33 +2266,38 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null) if (hit1 || hitPoly1 is not null)
{ {
// #116 shape-1 fix (Campaign P final physics slice,
// 2026-07-30; docs/research/2026-07-30-ts4-116-oracle-plan.md
// §2.3-§2.4). Retail's airborne (not-yet-Contact)
// BSPTREE::find_collisions dispatch does NOT defer a
// foot-clear/head-hit through SetCollide+Adjusted (nor
// through the steep-poly slide-tangent shortcut) — it
// records the head polygon's normal directly and hard-
// stops, unconditionally, regardless of the poly's
// steepness:
// localtoglobalvec(sphere_path.localspace_pos, &n, &poly->plane.N);
// COLLISIONINFO::set_collision_normal(&collision_info, &n);
// return 2; // COLLIDED_TS
// (acclient_2013_pseudo_c.txt:323824-323834,
// 0x0053a793/0x0053a7a4). Independently cross-checked
// against ACE's clean-language port, BSPTree.cs:221-230
// (`collisions.SetCollisionNormal(collisionNormal);
// return TransitionState.Collided;`) — an exact
// structural match confirming this isn't a BN
// misdecompile. This was the tick-22760 divergence:
// acdream's foot-clear/head-hit case previously fell
// through to SetCollide (deferred Adjusted, no
// CollisionNormal write) or the steep-poly Slid
// shortcut, so ValidateTransition's UnitZ default
// clobbered the real door-face normal before the
// caller ever saw it.
var worldNormal1 = L2W(hitPoly1!.Plane.Normal); var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
// L.4 slide-tangent: same steep-poly slide for head-sphere. collisions.SetCollisionNormal(worldNormal1);
if (worldNormal1.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
Vector3 endWorld = path.GlobalSphere[0].Origin;
Vector3 gDelta = endWorld - currWorld;
float diff = Vector3.Dot(worldNormal1, gDelta);
if (diff < 0f)
path.AddOffsetToCheckPos(-worldNormal1 * diff);
collisions.SetCollisionNormal(worldNormal1);
collisions.SetSlidingNormal(worldNormal1);
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
return TransitionState.Slid;
}
// Head sphere hit shallow surface: SetCollide.
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
// L.2d slice 1 (2026-05-13): diagnostic side-channel. // L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1; PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
return TransitionState.Adjusted; return TransitionState.Collided;
} }
} }
} }