fix(physics): delete the query-site broadphase reach filter (#333, closing #337)

Transition.FindObjCollisionsInCell discarded a shadow candidate when
  |currPos - obj.Position| > sphereRadius + obj.Radius + movement.Length() + 2f

obj.Position is the part ORIGIN; obj.Radius is the physics-BSP ROOT
BOUNDING SPHERE's radius, measured about a centre AP-156 established is
frequently metres from that origin (376 of 973 installed physics-BSP
parts sit further from their part origin than half their own radius,
worst 20.762 m). Geometry deep inside the real bounding sphere was
therefore thrown away before BSPQuery ever ran: solid near the origin,
permeable in a bounded shell beyond it. For the Neftet rock 0xC8766009 /
gfx=0x01004751 the two points are 23.556 m apart, which is #337 — wedged
on the plateau, jumps sinking into the mesh, corpses falling through. A
live capture recorded 7,225 rejections on that one owner, every single
one with wouldAcceptAtCenter=True.

Deleted rather than re-centred. Retail has no distance pre-filter,
disassembled from the PDB-paired v11.4186 binary (CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from Binary Ninja:

  CObjCell::find_obj_collisions @0x0052b750 walks shadow_object_list and
  calls CPhysicsObj::FindObjCollisions (0x0052b78b) UNCONDITIONALLY; its
  only early-out is insert_type == INITIAL_PLACEMENT_INSERT (0x0052b759).
  CPhysicsObj::FindObjCollisions @0x0050f050 contains no float compare at
  all. CPartArray::FindObjCollisions @0x00518180 is a bare do/while over
  parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null
  checks plus a call. Retail's only spatial rejection is the BSP node
  bounding-sphere test inside the walk — correctly centred, which is
  exactly what the deleted filter was not.

Re-centring it (carry BoundsCenter on ShadowEntry) would have preserved
an invention retail does not have, including a +2f slack and a
movement.Length() term with no retail counterpart, and left a second
reach budget to be tuned forever. Retail's own cross-cell slack constant
is F_EPSILON = 0.0002 m, not 2 m.

The method's comment claimed the filter was "the analog of the part
sorting-sphere early-outs inside retail's CPhysicsObj::FindObjCollisions
— response-neutral, pure perf". Both halves were false and cost #333 and
#337; it is replaced by the disassembly above.

Gate: Issue333BroadphaseReachFilterTests drives the production path
end-to-end (ResolveWithTransition -> FindObjCollisionsInCell ->
CollisionTraversal) on a DAT-free fixture so it runs everywhere, as a
discriminating pair. Sabotage-verified: restore the pre-check and
OffCentreBspFloorStopsAFallingMover reaches z=37.800 — exactly the
unobstructed fall, blockedAtLeastOnce=False — while
CentredBspFloorStopsAFallingMover keeps passing. Without the control a
fixture unable to fall would pass the first test for the wrong reason.

Issue337's skipped TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn
asserted the now-deleted predicate and could never have gone green; it
is rewritten as installed-DAT evidence pinning BOTH halves of the
diagnosis and is no longer skipped.

Perf measured, not assumed (Release, synthetic all-BSP cell, per
ResolveWithTransition): at 38 candidates — the live maximum — 10.61 us ->
16.68 us (1.57x); at a deliberately unreachable 200, 17.34 -> 39.48 us
(2.28x); ~0.16 us per additional candidate tested. Over 19,701 live
[reach-q] samples the in-cell count is p50 = 9, p99 = 32, max 38.

The ACDREAM_PROBE_REACH rejectedReach column is kept and is now
structurally 0, so a post-fix capture stays comparable with the pre-fix
one; dropping it would make the two incomparable.

AP-158 retired (110 active AP rows). #333 and #337 closed pending the
user's live acceptance at Neftet.

Solution suite 11,231 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 22:16:53 +02:00
parent 5a1eeace73
commit ea83b043df
7 changed files with 478 additions and 85 deletions

View file

@ -24,10 +24,16 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — DIAGNOSED, fix not landed
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance
**Status:** OPEN — **mechanism proven offline 2026-08-06**, fix proposed and
awaiting approval. It is **#333**: the per-object broadphase in
**Status:** FIXED 2026-08-06 by #333's fix — the query-site broadphase reach
filter is **deleted**, because retail has none. Awaiting the user's live
acceptance at the Neftet plateau; the offline gate is
`Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover`,
sabotage-verified (restore the filter and it falls straight through to the
unobstructed height while the centred control keeps passing).
The mechanism, proven offline 2026-08-06, is **#333**: the per-object broadphase in
`Transition.FindObjCollisionsInCell` measures to the shadow entry's part
ORIGIN and compares against the BSP ROOT BOUNDING SPHERE's radius. Those are
23.6 m apart for `0xC8766009` / `gfx=0x01004751`, so a mover on the plateau is
@ -39,9 +45,13 @@ instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014
Full evidence + the proposed fix:
[`docs/research/2026-08-06-337-neftet-wedge-mechanism.md`](research/2026-08-06-337-neftet-wedge-mechanism.md).
Reproducer + offline replay:
`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs`
(the acceptance gate is the skipped
`TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`).
`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs`.
Its installed-DAT evidence row is
`TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn`, which pins
BOTH halves of the diagnosis for this rock — origin-measured distance outside
the old budget, centre-measured distance comfortably inside the same radius.
The production gate is the separate DAT-free
`Issue333BroadphaseReachFilterTests`.
**Historical framing below is superseded by that document** — in particular
"the mesh never collides" was true only of the innocent neighbour, and both
@ -156,8 +166,41 @@ Two additions close that:
## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested
**Status:** OPEN
**Severity:** high for the tall-prop population. It is the gate immediately
**Status:** FIXED 2026-08-06 — **the filter is deleted, not re-centred.**
Re-centring it would have kept an invention retail does not have; the
disassembly below establishes that retail walks the cell's shadow list
unconditionally. Cell membership IS retail's broad phase, and the BSP walk's
own root-node bounding-sphere test — correctly centred, which is exactly what
this filter was not — is the early-out that made a second one unnecessary.
**AP-158 is retired** by the same commit.
This closes **#337** (the Neftet plateau: wedged at the top, jumps sink in,
corpses fall through), whose mechanism this is.
**Gates.** `Issue333BroadphaseReachFilterTests` drives the production path
end-to-end (`ResolveWithTransition``FindObjCollisionsInCell`
`CollisionTraversal`) on a DAT-free fixture so it runs everywhere, and is
sabotage-verified as a discriminating pair: restore the `maxReach` pre-check
and `OffCentreBspFloorStopsAFallingMover` falls straight through to the
unobstructed 37.800 while `CentredBspFloorStopsAFallingMover` keeps passing —
so it cannot pass for the trivial reason that the fixture is unable to fall.
**Perf, measured rather than assumed.** Deleting a filter costs whatever the
candidates it used to reject now cost. Measured in Release on a synthetic
all-BSP cell, per `ResolveWithTransition`:
| candidates in cell | with filter | without | delta |
|---|---|---|---|
| 38 (the live max) | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) |
| 200 (5× worse than anything observed) | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) |
≈0.16 µs per additional candidate actually tested. The live population is the
bound that matters: over 19,701 `[reach-q]` samples in the Neftet and outdoor
captures (`334-fix-gate.log`, `334-neftet-probe.log`, `334-neftet.log`) the
in-cell candidate count is **p50 = 9, p99 = 32, max 38**. The 200-object row is
included only to show the curve is linear, not to suggest it is reachable.
**Severity (when open):** high for the tall-prop population. It was the gate immediately
downstream of the AP-156 membership fix, so that fix alone may not be enough to
make the worst objects block.
**Filed:** 2026-08-06 at the AP-156 fix (commit `b52967de`), which surfaced it.
@ -199,7 +242,12 @@ offset above the filter's roughly 2.5 m walking budget, and **46** above 5 m. At
a test scale of 1.75 those offsets become 4.4 m and 8.75 m against an unchanged
budget.
### Consequence for the AP-156 connected gate — read this before running it
### Consequence for the AP-156 connected gate — SUPERSEDED by the fix above
*The caveat below applied while this issue was open. It no longer holds: the
filter is gone, so AP-156's connected gate is now expected to show its benefit
on tall props, and a null result there IS evidence against AP-156. Retained for
the record.*
**Tall props may show NO VISIBLE CHANGE at all until this issue is fixed, and a
null result there is EXPECTED rather than evidence against AP-156.** AP-156 puts

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,9 @@
# #337 — the Neftet plateau wedge: mechanism, measured
**Date:** 2026-08-06
**Status:** mechanism proven offline; fix proposed, NOT landed.
**Status:** mechanism proven offline; **fix LANDED 2026-08-06 — the preferred
option below was taken, the filter is deleted.** Awaiting the user's live
acceptance at the Neftet plateau.
**Reproducer:** `tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs`
**Related:** #333 (filed: the broadphase reach filter has AP-156's defect at
the query site), #334 (`13fcf381`, registration extent walk), AP-156
@ -200,26 +202,71 @@ response-neutral, pure perf" is **wrong on both counts.**
---
## Proposed fix
## The fix, as landed
**Preferred — remove the per-object distance pre-check for BSP entries.**
Retail has none, and the BSP walk's own root node bounding-sphere test is the
correctly-centred early-out that makes it unnecessary. Size: delete ~10 lines
in `Transition.FindObjCollisionsInCell` plus the probe's `rejected-reach`
branch; correct the false retail-analog comment in the same commit. No
divergence-register row is created; if #333's row exists it is deleted.
**Taken: the preferred option — the per-object distance pre-check is DELETED**,
for BSP and primitive entries alike. Retail has none, and the BSP walk's own
root-node bounding-sphere test is the correctly-centred early-out that makes a
second one unnecessary. The comment that called the filter "the analog of the
part sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions`
— response-neutral, pure perf" was false in both halves and is replaced by the
disassembly that refutes it. **AP-158 is retired**; no new register row is
created, because the code no longer diverges.
**Fallback if a perf gate demands a filter** — measure to the bounding-sphere
centre, the AP-156 correction applied at the query site:
`obj.Position + Vector3.Transform(BoundsCenter * Scale, obj.Rotation)`.
`ShadowShape.BoundsCenter` already carries this value; `ShadowEntry` does not,
so this variant also touches `ShadowEntry` and both registration paths
(`Register` and `RegisterMultiPart`). Larger, and it keeps a non-retail
construct that then needs a register row.
The fallback — measuring to the bounding-sphere centre, which would have needed
`BoundsCenter` on `ShadowEntry` and both registration paths — was NOT taken. It
would have kept a construct retail does not have, including a `+ 2f` slack and
a `movement.Length()` term with no retail counterpart, and left a second reach
budget to be tuned forever.
Acceptance gate: un-skip
`Issue337NeftetRockGeometryInspectionTests.TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`.
Verified to fail today with the numbers above.
### Gates
`tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs` drives
the production path end-to-end (`ResolveWithTransition`
`FindObjCollisionsInCell``CollisionTraversal`) on a DAT-free fixture, so it
runs everywhere rather than only where the installed DATs are present. It is a
discriminating PAIR, sabotage-verified: with the `maxReach` pre-check restored,
`OffCentreBspFloorStopsAFallingMover` fails — the mover reaches z=37.800, which
is exactly the unobstructed fall, with `blockedAtLeastOnce=False` — while
`CentredBspFloorStopsAFallingMover` keeps passing. Without the control row, a
fixture that simply could not fall would pass the first test for the wrong
reason.
The installed-DAT evidence for THIS rock is
`Issue337NeftetRockGeometryInspectionTests.TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn`
(previously the skipped `TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`,
which asserted the now-deleted predicate and could never have gone green). It
pins both halves of the diagnosis: the origin-measured distance OUTSIDE the old
budget, and the centre-measured distance comfortably INSIDE the same radius. If
a future DAT or transform change makes either false, the mechanism recorded here
no longer describes this object.
### Perf — measured, not assumed
Deleting a filter costs whatever the candidates it used to reject now cost.
Measured in Release on a synthetic all-BSP cell, per `ResolveWithTransition`:
| candidates in cell | with filter | without | delta |
|---|---|---|---|
| 38 — the live maximum | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) |
| 200 — 5× anything observed | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) |
≈ 0.16 µs per additional candidate actually tested; the curve is linear, and
the 200-object row is included only to show that, not to suggest it is
reachable. The live population is the bound that matters: over **19,701**
`[reach-q]` samples across the Neftet and outdoor captures (`334-fix-gate.log`,
`334-neftet-probe.log`, `334-neftet.log`) the in-cell candidate count is
**p50 = 9, p99 = 32, max 38**. Retail pays the same cost and shipped without a
filter.
### Probe columns kept deliberately
`rejectedReach` on the `[reach-q]` line and the origin-vs-centre distance pair
on `[reach-obj]` are RETAINED and are now structurally zero / purely
informational. That is the point: a post-fix capture reading `rejectedReach=0`
is directly comparable with the pre-fix capture that recorded **7,225**
rejections on a single owner, every one of them with `wouldAcceptAtCenter=True`.
Dropping the columns would make the two captures incomparable.
---