From ea83b043dfaaf96cf579ae614154c5f37f59c701 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 22:16:53 +0200 Subject: [PATCH] fix(physics): delete the query-site broadphase reach filter (#333, closing #337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 66 ++++- .../retail-divergence-register.md | 4 +- .../2026-08-06-337-neftet-wedge-mechanism.md | 83 ++++-- .../Physics/PhysicsDiagnostics.cs | 27 +- src/AcDream.Core/Physics/TransitionTypes.cs | 79 ++++-- .../Issue333BroadphaseReachFilterTests.cs | 240 ++++++++++++++++++ ...sue337NeftetRockGeometryInspectionTests.cs | 64 +++-- 7 files changed, 478 insertions(+), 85 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9311bf3d..5de3df89 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c1b2bc6c..f0c8b2f7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -163,7 +163,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 111 active rows (AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 110 active rows (AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -186,7 +186,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-155 | **Filed 2026-08-06 at the AP-152 retirement; NARROWED 2026-08-06 to its static-publication half alone.** Its flood half was bundled here with a different code path, a different population and a different gate — the exact fault the C4 handoff warns about — and its direction was recorded BACKWARDS; both are now split out as AP-156. **Static paths emit a Setup Sphere as a height-capped CYLINDER.** `LandblockPhysicsPublisher.cs:1030-1037` and `LandblockPhysicsContentBuilder.cs:683-690` both convert a Setup Sphere to `ShadowCollisionType.Cylinder` with `CylHeight = radius * 2f` and the origin shifted down by one radius; the live path emits a true `ShadowCollisionType.Sphere`, produced at exactly ONE site in `src/` (`ShadowShapeBuilder.cs`). Retail tests a Setup Sphere with `CSphere::intersects_sphere` @0x00537a80 / @0x00537fd0 (two overloads) in both cases — 3-D distance, no height clamp. The static paths also derive "has BSP" from `entity.MeshRefs` (the render mesh list) where the live path derives it from `setup.Parts` plus the effective post-`AnimPartChanged` identities; the two sources can disagree. | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1030-1037`; `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:683-690` | Affects static props only and changes their collision geometry over a much larger population than AP-152's 172, so it needs its own count and its own gate. Deliberately not folded into the AP-152 or AP-156 commits. | A static prop whose Setup carries a Sphere blocks over a height-clamped cylinder instead of a true sphere, and rests one radius lower than the authored origin. | `CSphere::intersects_sphere` 0x00537a80 / 0x00537fd0 | | AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. **OUTDOOR HALF CLOSED 2026-08-06 by #334 (see AP-159 for what remains).** | **RISK COLUMN CORRECTED 2026-08-06 at the #334 fix — as written below it was FALSE, and its falsity is what let #334 sit unnoticed inside this row.** It generalised the INDOOR direction (sphere-vs-portal-plane, over-inclusive) to the whole residual. The OUTDOOR direction was the opposite and strictly worse: acdream routed BSP-bearing objects through `CObjCell::find_cell_list`, whose outdoor expansion is a hard-capped ±1-cell 3×3 for ANY radius, so every formation wider than one 24 m land cell was MISSED in its outer cells — a user-observed loss of collision, not extra candidates. Original text, retained for the record: *"A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed."* That statement now holds only for the indoor half, which is AP-159. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | | AP-157 | **Filed 2026-08-06 at the AP-152 retail-conformance review (finding F4) — an unregistered substitution that predates AP-152 and was stepped over when its neighbours were filed.** `CPhysicsObj::calc_cross_cells`' THIRD branch (`0x005152dc` -> `CPartArray::GetSortingSphere` @0x00518b00 -> `CObjCell::find_cell_list` @0x0052b990) floods from ONE authored whole-object sphere: `GetSortingSphere` returns `[partArray+0x54] + 0x70`, i.e. `CSetup::sorting_sphere` (acclient.h: `CSetup` carries `CSphere sorting_sphere` immediately after `step_up_height`), and that overload takes a single sphere with no cap. acdream's `only == null` branch floods from EVERY non-BSP, non-Cylinder shape instead — the Setup's per-part `Spheres` array. Different DAT field, different cardinality, different extent. 4,154 of 5,935 installed Setups carry a non-zero `SortingSphere` and `DatReaderWriter.Setup` already exposes it, so this is available rather than blocked. Same site, second item: `BuildFloodSpheres` collapses a Cylinder to one sphere at its BASE point with the cylinder radius and IGNORES `CylHeight` entirely, where retail's `CObjCell::find_cell_list` @0x0052b9f0 is handed the `CCylSphere` array as `(low_pt, radius, height)`. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`, the `anyCyl` and `only == null` branches) | Deliberately NOT folded into the AP-156 fix. It is a different branch of `calc_cross_cells`, reached only by objects with neither a physics BSP nor a CylSphere, so its population is disjoint from the 172 AP-152 Setups and its live gate is a different set of objects. Bundling it would make the AP-156 connected gate un-attributable — which is exactly how AP-155 came to carry two lifecycles under one id. | Sorting-sphere half: an object with several authored Spheres floods from all of them rather than from the one authored whole-object sphere — usually wider (max 5 Spheres on any installed Setup, so retail's 10-cap is never the difference), but a `sorting_sphere` LARGER than every per-part Sphere would make acdream under-inclusive, the #98 / #168 direction. CylHeight half: a tall thin cylinder floods a sphere of its radius at its base and can miss the cells its upper half occupies. | `CPhysicsObj::calc_cross_cells` 0x00515230 (0x005152dc / 0x005152e3 / 0x005152fb); `CPartArray::GetSortingSphere` 0x00518b00 (`[+0x54]+0x70`); `CObjCell::find_cell_list` 0x0052b990 (sorting sphere) / 0x0052b9f0 (cylsphere, `(low_pt, radius, height)`) | -| AP-158 | **Filed 2026-08-06 at the AP-156 fix review (finding A2) — an UNREGISTERED INVENTION, not a port, that predates AP-156 and is issue #333.** The shadow broadphase discards a candidate outright when `distToCurr > sphereRadius + obj.Radius + movement.Length() + 2f`. **Retail has no distance pre-filter at all.** `CObjCell::find_obj_collisions` @0x0052b750, disassembled from the PDB-paired binary for this row rather than inherited: it early-returns `OK_TS` only when `sphere_path.insert_type == INITIAL_PLACEMENT_INSERT` (`0x0052b759 cmp dword [ebx+0x174],2` / `0x0052b765 je 0x52b7a0`), then walks `shadow_object_list` (`[cell+0xc8]`, count `[cell+0xc4]`) and calls `CPhysicsObj::FindObjCollisions` (`0x0052b78b call 0x50f050`) on every entry whose `physobj` is unparented (`[physobj+0x40] == 0`) and is not the mover itself — UNCONDITIONALLY. There is no distance test in the function. Neither the `+ 2f` slack nor the `movement.Length()` term has a retail counterpart; retail's own cross-cell slack constant is `F_EPSILON` = 1.9999999e-4 m (`0x0052cb5f fld dword [0x7c8c70]`), 0.0002 m and not 2 m. **Second half of the defect:** the filter measures `currPos - obj.Position`, i.e. from the PART ORIGIN, while `obj.Radius` is the BSP root bounding-sphere radius measured about a centre that AP-156 established is frequently metres away — `ShadowEntry` does not carry the `BoundsCenter` that `ShadowShape` now does. A mover touching the geometry is up to `d + R + r` from the part origin and is admitted only when `d <= movement + 2`, roughly 2.5 m for a walking player. | `src/AcDream.Core/Physics/TransitionTypes.cs:3757-3765`; `ShadowEntry` (`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:2735`) carries no `BoundsCenter` | Deliberately NOT folded into the AP-156 commit: different code path (collision query, not cell membership) and it needed its own retail question answered, which this row answers. The minimal fix is mechanical — carry `BoundsCenter` on `ShadowEntry` and measure from `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`; only whether to keep the `+ 2f` slack at all is genuinely open. | **This is the gate immediately downstream of AP-156, and it can mask AP-156's entire visible benefit.** 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above the ~2.5 m budget and 46 above 5 m; at a test scale of 1.75 those become 4.4 m and 8.75 m against an unchanged budget. Worked case: Setup 0x02000255, one part, root sphere origin (0.000, -0.007, 9.911), radius 10.522 — a player against its upper half is ~20.4 m from the part origin while `maxReach` is ~13.5 m. Discarded before `BSPQuery` ever runs. A tall prop that still does not block after AP-156 is THIS row, not a failure of AP-156. | `CObjCell::find_obj_collisions` 0x0052b750 (0x0052b759 / 0x0052b765 / 0x0052b788 / 0x0052b78b), pseudo-C 308916-308940; `CEnvCell::find_transit_cells` 0x0052cae0 (`F_EPSILON` at 0x0052cb5f -> 0x7c8c70); issue #333 | +| ~~AP-158~~ | **RETIRED 2026-08-06 — the filter is DELETED, not re-centred, and this row's own disassembly is why.** The minimal fix this row proposed (carry `BoundsCenter` on `ShadowEntry` and measure from the true centre) was deliberately NOT taken: it would have preserved an invention retail does not have, kept a `+ 2f` slack and a `movement.Length()` term with no retail counterpart, and left a second reach budget to be tuned forever. `Transition.FindObjCollisionsInCell` now walks the cell's shadow list with no distance pre-check at all, as `CObjCell::find_obj_collisions` @0x0052b750 does. Cell membership is retail's broad phase, and the BSP walk's own root-node bounding-sphere test — centred correctly, which is precisely what this filter was not — is the early-out that made a second one unnecessary. **This retirement closes #333 and #337** (the Neftet plateau: wedged at the top, jumps sinking into the mesh, corpses falling through), whose mechanism it was. **The row's predicted symptom was observed live before it was fixed**, which is the strongest confirmation a register row gets: it predicted a tall prop AP-156 had just placed correctly would still not block, and the user reported exactly that at Neftet. **PERF, MEASURED rather than assumed** (Release, synthetic all-BSP cell, per `ResolveWithTransition`): at 38 candidates — the live maximum — 10.61 µs → 16.68 µs (+6.07, 1.57×); at a deliberately unreachable 200, 17.34 µs → 39.48 µs (2.28×); ≈ 0.16 µs per additional candidate tested. Over 19,701 live `[reach-q]` samples the in-cell candidate count is p50 = 9, p99 = 32, max 38, so the first row is the bound that matters. **Original text, retained for the record:** **Filed 2026-08-06 at the AP-156 fix review (finding A2) — an UNREGISTERED INVENTION, not a port, that predates AP-156 and is issue #333.** The shadow broadphase discards a candidate outright when `distToCurr > sphereRadius + obj.Radius + movement.Length() + 2f`. **Retail has no distance pre-filter at all.** `CObjCell::find_obj_collisions` @0x0052b750, disassembled from the PDB-paired binary for this row rather than inherited: it early-returns `OK_TS` only when `sphere_path.insert_type == INITIAL_PLACEMENT_INSERT` (`0x0052b759 cmp dword [ebx+0x174],2` / `0x0052b765 je 0x52b7a0`), then walks `shadow_object_list` (`[cell+0xc8]`, count `[cell+0xc4]`) and calls `CPhysicsObj::FindObjCollisions` (`0x0052b78b call 0x50f050`) on every entry whose `physobj` is unparented (`[physobj+0x40] == 0`) and is not the mover itself — UNCONDITIONALLY. There is no distance test in the function. Neither the `+ 2f` slack nor the `movement.Length()` term has a retail counterpart; retail's own cross-cell slack constant is `F_EPSILON` = 1.9999999e-4 m (`0x0052cb5f fld dword [0x7c8c70]`), 0.0002 m and not 2 m. **Second half of the defect:** the filter measures `currPos - obj.Position`, i.e. from the PART ORIGIN, while `obj.Radius` is the BSP root bounding-sphere radius measured about a centre that AP-156 established is frequently metres away — `ShadowEntry` does not carry the `BoundsCenter` that `ShadowShape` now does. A mover touching the geometry is up to `d + R + r` from the part origin and is admitted only when `d <= movement + 2`, roughly 2.5 m for a walking player. | `src/AcDream.Core/Physics/TransitionTypes.cs:3757-3765`; `ShadowEntry` (`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:2735`) carries no `BoundsCenter`; **RETIRED:** the pre-check is gone from `FindObjCollisionsInCell` and `ShadowEntry` needs no `BoundsCenter`. Tests `Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover` (production path end-to-end, DAT-free, sabotage-verified against its `CentredBspFloorStopsAFallingMover` control — restore the pre-check and the mover falls straight through to the unobstructed 37.800 while the control still blocks) and `Issue337NeftetRockGeometryInspectionTests.TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn` (installed-DAT evidence, both halves of the diagnosis) | Deliberately NOT folded into the AP-156 commit: different code path (collision query, not cell membership) and it needed its own retail question answered, which this row answers. The minimal fix is mechanical — carry `BoundsCenter` on `ShadowEntry` and measure from `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`; only whether to keep the `+ 2f` slack at all is genuinely open. | **RETIRED — no residual.** The `rejectedReach` column of the `ACDREAM_PROBE_REACH` family is kept and is now structurally 0, precisely so a post-fix capture is directly comparable with the pre-fix one that recorded 7,225 rejections on a single owner, every one with `wouldAcceptAtCenter=True`. Original risk text, retained for the record: **This is the gate immediately downstream of AP-156, and it can mask AP-156's entire visible benefit.** 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above the ~2.5 m budget and 46 above 5 m; at a test scale of 1.75 those become 4.4 m and 8.75 m against an unchanged budget. Worked case: Setup 0x02000255, one part, root sphere origin (0.000, -0.007, 9.911), radius 10.522 — a player against its upper half is ~20.4 m from the part origin while `maxReach` is ~13.5 m. Discarded before `BSPQuery` ever runs. A tall prop that still does not block after AP-156 is THIS row, not a failure of AP-156. | `CObjCell::find_obj_collisions` 0x0052b750 (0x0052b759 / 0x0052b765 / 0x0052b788 / 0x0052b78b), pseudo-C 308916-308940; `CEnvCell::find_transit_cells` 0x0052cae0 (`F_EPSILON` at 0x0052cb5f -> 0x7c8c70); issue #333 | | AP-159 | **Filed 2026-08-06 at the #334 fix - the INDOOR half of AP-156's traversal residual, now the whole of it.** #334 ported retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path so a physics-BSP object's OUTDOOR membership is the filled land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans (`CLandCell::add_all_outside_cells` @0x00533360 -> `add_cell_block` @0x005331d0). The INDOOR arm of that same walk is NOT ported: retail's part-array `CEnvCell::find_transit_cells` @0x0052cae0 admits a neighbour cell on a BOX test - `CPhysicsPart::GetBoundingBox` @0x0050d600 -> `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) -> `Plane::intersect_box` @0x005aa170 (`0x0052cc05`), then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 -> `BSPTREE` @0x0053c880 - where acdream keeps `CellTransit.FindTransitCellsSphere`'s sphere-vs-portal-plane test, fed from the SAME per-part `CGfxObj::physics_sphere` values retail uses for its cheap `eps = F_EPSILON + radius` pre-reject at `0x0052cb65`. The outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0) is on the same sphere input for the same reason. Deferred deliberately: closing it needs a new BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations plus their exact referee, which is a separately gateable change with no bearing on #334's outdoor defect. Filed as issue #335. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts`, indoor arm); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`) | The sphere set is exact (AP-156) and the sphere is a strictly LOOSER admitter than the box for a convex part, so the indoor set is a superset of retail's. Retail is itself conservative here in four compounding ways (render-mesh AABB over physics hull, axis-aligned re-fit after rotation, filled rectangle over per-cell test, one rectangle unioned across parts), so an over-inclusive indoor set is the same direction retail errs in. | A cell whose portal plane a part's sphere straddles but whose box does not joins the object's shadow set: extra broadphase candidates, never a missed one. This is AP-156's original risk statement, which is true of the indoor half and was false of the outdoor half. | `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cbdd / 0x0052cbf9 / 0x0052cc05 / 0x0052cc5a); `Plane::intersect_box` 0x005aa170; `CCellStruct::box_intersects_cell` 0x00533910; `CEnvCell::check_building_transit` 0x0052c5d0 | | ~~AP-152~~ | **RETIRED 2026-08-06 (the commit that filed it is one day old; this retirement corrects four statements in it).** `ShadowShapeBuilder.FromSetup` now DISPATCHES instead of unioning: a step-0 gate derived from the parts suppresses steps 1 and 2 whenever any part's EFFECTIVE GfxObj carries a physics BSP. Retail's priority, re-disassembled from the PDB-paired binary for this commit rather than inherited: `CPhysicsObj::FindObjCollisions` @0x0050f050 tests `HAS_PHYSICS_BSP_PS` FIRST (`0x0050f165 test dword [esi+0xa8],0x10000` / `0x0050f16f je 0x50f1a2`) and leaves the BSP branch through the UNCONDITIONAL `0x0050f19d jmp 0x50f2b0`, which is past the CylSphere loop at 0x50f1a2 AND the Sphere loop at 0x50f21d; a CylSphere-bearing object that survives its loop RETURNS (`0x0050f1d6 jae 0x50f317`); a Setup with zero spheres returns the seeded OK_TS (`0x0050f22f je 0x50f31b`). **BSP wins.** **CORRECTION 1 — the row's risk statement was FALSE as written.** It predicted "catching or stopping on a doorway sill". acdream did not test the extra primitive either: `Transition.BspOnlyDispatch` (`TransitionTypes.cs:1348`, landed 2026-05-25 as A6.P7) already skipped BOTH primitive branches (`:3911`, `:3954`) whenever the target's wire `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` (`WorldObject_Networking.cs:665-668`). The row's own anchor column cites the flag it failed to notice acdream was already keying on. So this retirement is NOT a collision-response change; the live half was CELL MEMBERSHIP, which had no such guard (see AP-155). **CORRECTION 2 — "the affected primitives are small and centred at the part origin" was FALSE in both halves.** The largest is `0x02001741`'s CylSphere at **r = 6.714 m**; `0x0200086E`'s Sphere is r = 5.842 m with origin (0.759, 0.165, 5.842), nowhere near the part origin. **CORRECTION 3 — the cottage door's "~14 cm base Sphere" was the wrong field.** `0x020019FF`'s Sphere radius is **0.100 m** at origin (0, 0, 0.018); `0.141` is `Setup.Radius`, which AP-22 had just finished proving is never collision geometry. **CORRECTION 4 — the row named ONE pinning test where TWO existed.** `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` also failed under the exclusive rule; both are corrected, neither deleted. Population re-measured independently at 172 of 5,935 (73 CylSphere+BSP, 99 Sphere+BSP; 530 carry a physics-BSP part), agreeing exactly with the filing commit's separate sweep, and now pinned by an installed-DAT test with external bucket controls. | RETIRED — `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0 gate + `EffectivePartGfxObjId`, shared with step 3 so the two can never read different identities); `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_EmitsBspPartsOnly`, `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, `FromSetup_DispatchGateReadsTheEffectivePartIdentities`); `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` (`CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape` — no App fixture combined a primitive with a BSP part before); `tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs` (population). `Transition.BspOnlyDispatch` is deliberately KEPT: retail genuinely dispatches at the query site too, and it guards against a future additive producer. | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f); `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 / 0x0051528f) -> `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPhysicsPart::find_obj_collisions` 0x0050d8d0; `CPartArray::CacheHasPhysicsBSP` 0x00518110; evidence `docs/research/2026-08-06-ap152-contract.md` | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | diff --git a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md index 54cad1ec..c32b3747 100644 --- a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md +++ b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md @@ -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. --- diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index ae785f2e..4c470455 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1158,7 +1158,13 @@ public static class PhysicsDiagnostics // confirm any one of them: // // (a) the object IS a candidate in the cell but the broadphase reach - // filter rejects it before its BSP is consulted (AP-158 / #333); + // filter rejects it before its BSP is consulted (AP-158 / #333). + // CONFIRMED and CLOSED 2026-08-06: this was the cause of #337, and + // the filter is now DELETED — retail has none. `rejectedReach` is + // retained as a structurally-zero column so a post-fix capture is + // directly comparable with the pre-fix one that recorded 7,225 + // rejections on one owner, every single one with + // wouldAcceptAtCenter=True; // (b) the object is NOT in the cell's candidate set at all — a // membership / registration failure (AP-156's territory, which did // not fix this, or the static publication path never registered it); @@ -1199,7 +1205,9 @@ public static class PhysicsDiagnostics /// [reach-obj] — one per candidate, carrying its identity /// (mover guid, target entity id, GfxObj id, cell) and its /// disposition: exempt-self, exempt-missile, - /// rejected-reach, exempt-rule, + /// exempt-rule + /// (rejected-reach is retired — #333 deleted the filter that + /// produced it), /// exempt-ethereal-stepdown, no-shape, /// bsp-only-skip, or tested:<result>. For BSP /// candidates it also carries the origin-measured distance the filter @@ -1250,8 +1258,10 @@ public static class PhysicsDiagnostics /// What it should have measured: the distance to /// the target's physics-BSP root sphere CENTRE. Negative when not /// applicable. - /// The filter's admission threshold, 2 m slack - /// included. + /// What the deleted filter's admission threshold WOULD + /// have been, 2 m slack included. Since #333 no live predicate reads it; it + /// is kept so the acceptance capture shows which candidates the old filter + /// would have thrown away. /// The same threshold WITHOUT the slack — the /// honest conservative bound once the real centre is used. public static void LogReachCandidate( @@ -1318,10 +1328,13 @@ public static class PhysicsDiagnostics /// /// Shadow entries the cell yielded, before any /// exemption. Zero here at a spot with visible geometry is outcome (b). - /// Candidates that survived the exemptions and were - /// measured by the reach filter. + /// Candidates that survived the exemptions and went + /// on to a shape dispatch. Before #333 this was "and were measured by the + /// reach filter"; the filter is gone, so the two are now the same set. /// Of those, how many the reach filter - /// rejected — outcome (a). + /// rejected — outcome (a). Structurally 0 since #333 deleted the + /// filter, and retained precisely so that a post-fix capture reading 0 + /// is comparable against the pre-fix capture that read 7,225. /// Candidates that passed the filter but resolved to /// no usable shape — outcome (c). /// Candidates that actually reached a shape test. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index ac3632c8..556998c4 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3719,9 +3719,41 @@ public sealed class Transition /// /// /// - /// The per-object distance pre-check below is the analog of the part - /// sorting-sphere early-outs inside retail's - /// CPhysicsObj::FindObjCollisions — response-neutral, pure perf. + /// There is no per-object distance pre-check here, because retail has + /// none. An earlier comment on this method claimed the filter that used + /// to sit in the candidate loop was "the analog of the part sorting-sphere + /// early-outs inside retail's CPhysicsObj::FindObjCollisions — + /// response-neutral, pure perf". Both halves were false, and it cost + /// issues #333 and #337. Disassembled from the PDB-paired v11.4186 binary + /// (CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32): + /// + /// CObjCell::find_obj_collisions @0x0052b750 walks + /// shadow_object_list and calls CPhysicsObj::FindObjCollisions + /// (@0x0050f050, 0x0052b78b call) UNCONDITIONALLY; its only + /// early-out is insert_type == INITIAL_PLACEMENT_INSERT + /// (0x0052b759 cmp dword [ebx+0x174],2). + /// CPhysicsObj::FindObjCollisions @0x0050f050 contains no + /// float compare at all. Its BSP branch reaches + /// CPartArray::FindObjCollisions (0x0050f18d call 0x518180) + /// directly; the cylsphere and sphere loops are bounded only by + /// GetNumCylsphere/GetNumSphere counts and call the real + /// CCylSphere::intersects_sphere / CSphere::intersects_sphere + /// per primitive — tests, not pre-filters. There are no "sorting-sphere + /// early-outs" in it. + /// CPartArray::FindObjCollisions @0x00518180 is a bare + /// do/while over parts[i], and + /// CPhysicsPart::find_obj_collisions @0x0050d8d0 does two null + /// checks (gfxobj, then gfxobj->physics_bsp at + /// [ecx+0x78]), SPHEREPATH::cache_localspace_sphere, and + /// CGfxObj::find_obj_collisions @0x00534700. Neither contains a + /// compare or any float math. + /// + /// Retail's only spatial rejection is the BSP node bounding-sphere test + /// INSIDE the walk, which is centred on the node's own sphere origin and + /// is therefore correct where the deleted filter was not: it measured to + /// the part ORIGIN and compared against the BSP ROOT SPHERE's radius, two + /// points that are 23.556 m apart for the Neftet rock of #337. See + /// docs/research/2026-08-06-337-neftet-wedge-mechanism.md. /// /// private TransitionState FindObjCollisionsInCell(PhysicsEngine engine, uint cellId) @@ -3774,7 +3806,13 @@ public sealed class Transition using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); // #334 probe tallies — see LogReachQuery for what each one decides. - int rExempt = 0, rReached = 0, rRejected = 0, rNoShape = 0, + // `rejectedReach` is deliberately still REPORTED and structurally 0 + // since #333 deleted the filter: a run of the acceptance capture whose + // [reach-q] lines read rejectedReach=0 where the pre-fix capture read + // 7,225 rejections is the evidence the filter is gone, and dropping the + // column would make the two captures incomparable. + const int rRejected = 0; + int rExempt = 0, rReached = 0, rNoShape = 0, rTested = 0, rBlocked = 0; foreach (ShadowEntry obj in nearbyObjs.Entries) @@ -3836,25 +3874,12 @@ public sealed class Transition continue; } - // Broad-phase: can the moving sphere reach this object? - Vector3 deltaToCurr = currPos - obj.Position; - float distToCurr; - if (obj.CollisionType == ShadowCollisionType.Cylinder) - distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y); - else - distToCurr = deltaToCurr.Length(); - float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f; + // NO BROAD-PHASE DISTANCE FILTER — see the method's remarks. Retail + // walks the cell's shadow list unconditionally (#333, closing #337). + // Cell membership IS the broad phase, and the BSP walk's own root + // node bounding-sphere test — correctly centred, unlike the deleted + // filter — is the early-out that made this one unnecessary. if (reachProbe) rReached++; - if (distToCurr > maxReach) - { - if (reachProbe) - { - rRejected++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "rejected-reach", sphereRadius, movement.Length(), currPos); - } - continue; - } // Commit C 2026-04-29 — retail exemption block at the top of // CPhysicsObj::FindObjCollisions @@ -4338,10 +4363,12 @@ public sealed class Transition ? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y) : dOrigin.Length(); - // World-space offset from the part ORIGIN (what the filter measures - // against) to the BSP root sphere CENTRE (what it should measure - // against). Zero for non-BSP shapes, whose Position already IS their - // centre. + // World-space offset from the part ORIGIN (what the DELETED filter + // measured against) to the BSP root sphere CENTRE (what it should have + // measured against). Both distances are still emitted after #333: the + // pair is what proved the diagnosis, and it stays comparable across the + // pre-fix and post-fix captures. Zero for non-BSP shapes, whose + // Position already IS their centre. Vector3 bspCentreOffset = Vector3.Zero; if (obj.CollisionType == ShadowCollisionType.BSP) { diff --git a/tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs b/tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs new file mode 100644 index 00000000..22589e9b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #333 / #337 regression — the query-site broadphase reach filter. +/// +/// +/// Transition.FindObjCollisionsInCell used to discard a shadow candidate +/// when |currPos - obj.Position| > sphereRadius + obj.Radius + +/// movement.Length() + 2f. obj.Position is the part ORIGIN while +/// obj.Radius is the physics-BSP ROOT BOUNDING SPHERE's radius, measured +/// about a centre that is frequently metres away from that origin (AP-156: +/// 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 rejected before BSPQuery ever ran — +/// solid near the origin, permeable in a bounded shell beyond it. That is the +/// mechanism of #337 (the Neftet plateau: wedged on top, jumps sink in, +/// corpses fall through), measured in +/// docs/research/2026-08-06-337-neftet-wedge-mechanism.md. +/// +/// +/// +/// Retail has no such filter. Disassembled from the PDB-paired v11.4186 +/// binary: CObjCell::find_obj_collisions @0x0052b750 calls +/// CPhysicsObj::FindObjCollisions unconditionally, +/// CPhysicsObj::FindObjCollisions @0x0050f050 has no float compare at +/// all, CPartArray::FindObjCollisions @0x00518180 is a bare loop over +/// parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null +/// checks plus CGfxObj::find_obj_collisions @0x00534700. +/// +/// +/// +/// This fixture is deliberately DAT-free so the gate runs everywhere, and it +/// drives the PRODUCTION path end-to-end +/// (PhysicsEngine.ResolveWithTransition → +/// Transition.FindObjCollisionsInCellCollisionTraversal) +/// rather than re-computing a predicate in the test. Sabotage: restore the +/// maxReach pre-check and +/// fails while keeps passing — +/// the pair discriminates "the filter is gone" from "the fixture cannot fall". +/// +/// +public sealed class Issue333BroadphaseReachFilterTests +{ + private const uint LandblockId = 0xA9B60000u; + private const uint CellId = LandblockId | 0x0001u; + private const uint EntityId = 0x333BEEF1u; + private const uint GfxObjId = 0x333BEEF2u; + private const ushort FloorPolyId = 1; + + private const float SphereRadius = 0.48f; + private const float SphereHeight = 1.835f; // human Setup 0x02000001 + private const float StepUpHeight = 0.60f; + private const float StepDownHeight = 0.04f; + + /// Part origin. The filter measured to THIS point. + private static readonly Vector3 PartOrigin = new(12f, 12f, 0f); + + /// + /// Root bounding-sphere radius the registry publishes for this object, + /// and the radius of the single BSP leaf node. + /// + private const float RootSphereRadius = 6f; + + /// + /// #333 REGRESSION. A floor slab whose geometry — and whose BSP root + /// bounding sphere — sit 40 m above the part origin must stop a mover + /// falling onto it. + /// + /// + /// With the deleted filter the mover was 40.48 m from the part origin + /// against a budget of 0.48 + 6 + ~0.3 + 2 = 8.78 m, so the candidate was + /// discarded and the mover fell straight through. Measured to the root + /// sphere's own centre it is 1.48 m against a 6 m radius — inside by + /// 4.5 m of margin. Retail's BSP node test uses that centre; the filter + /// used the origin. + /// + /// + [Fact] + public void OffCentreBspFloorStopsAFallingMover() + { + var engine = BuildEngineWithFloorSlab(slabLocalZ: 40f); + + // Precondition, so a membership failure cannot masquerade as the + // defect this test is about. + Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(CellId)); + + var (finalFeetZ, blocked) = DropOnto(engine, startFeetZ: 41.4f); + + Assert.True( + finalFeetZ > 39.5f, + $"the mover fell through a floor slab 40 m above its owner's part " + + $"origin: feet reached z={finalFeetZ:F3}, and an unobstructed " + + $"fall would have reached 37.8. blockedAtLeastOnce={blocked}. " + + $"This is #333 — the query-site broadphase discarded the " + + $"candidate before BSPQuery ever ran."); + } + + /// + /// CONTROL. The identical slab, this time AT the part origin, so the + /// deleted filter admitted it. It blocked before the fix and must still + /// block after. Without this row, a fixture that simply cannot fall would + /// pass for the wrong + /// reason. + /// + [Fact] + public void CentredBspFloorStopsAFallingMover() + { + var engine = BuildEngineWithFloorSlab(slabLocalZ: 0f); + + Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(CellId)); + + var (finalFeetZ, blocked) = DropOnto(engine, startFeetZ: 1.4f); + + Assert.True( + finalFeetZ > -0.5f, + $"the control slab at the part origin must still stop the mover; " + + $"feet reached z={finalFeetZ:F3}, blockedAtLeastOnce={blocked}."); + } + + /// + /// Drop the mover straight down in 0.30 m steps for 12 ticks. Returns the + /// final FEET height and whether any tick reported a collision normal. + /// + private static (float FinalFeetZ, bool Blocked) DropOnto( + PhysicsEngine engine, float startFeetZ) + { + var pos = new Vector3(PartOrigin.X, PartOrigin.Y, startFeetZ); + bool blocked = false; + + for (int tick = 0; tick < 12; tick++) + { + Vector3 target = pos - new Vector3(0f, 0f, 0.30f); + var result = engine.ResolveWithTransition( + pos, target, CellId, + SphereRadius, SphereHeight, + StepUpHeight, StepDownHeight, + isOnGround: false, + body: null, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0); + + blocked |= result.CollisionNormalValid; + pos = result.Position; + } + + return (pos.Z, blocked); + } + + /// + /// One static BSP owner at carrying a single + /// horizontal floor polygon (8 × 8 m, normal +Z) at + /// in the owner's local frame, with the BSP + /// root node's bounding sphere centred on the slab — exactly the + /// origin-vs-centre offset the DAT authors for large formations. + /// + private static PhysicsEngine BuildEngineWithFloorSlab(float slabLocalZ) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // Flat terrain far below: it must never be what stops the mover. + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock( + landblockId: LandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: System.Array.Empty(), + portals: System.Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + var floorVerts = new[] + { + new Vector3(-4f, -4f, slabLocalZ), + new Vector3( 4f, -4f, slabLocalZ), + new Vector3( 4f, 4f, slabLocalZ), + new Vector3(-4f, 4f, slabLocalZ), + }; + var floorNormal = new Vector3(0f, 0f, 1f); + var floorPoly = new ResolvedPolygon + { + Vertices = floorVerts, + Plane = new Plane(floorNormal, -Vector3.Dot(floorNormal, floorVerts[0])), + NumPoints = 4, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, slabLocalZ), + Radius = RootSphereRadius, + }, + }; + leaf.Polygons.Add(FloorPolyId); + + var physics = new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = leaf }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = new Dictionary { [FloorPolyId] = floorPoly }, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, slabLocalZ), + Radius = RootSphereRadius, + }, + }; + cache.RegisterGfxObjForTest(GfxObjId, physics); + + // Registration publishes the ROOT SPHERE'S RADIUS about the PART + // ORIGIN — the exact pairing the deleted filter then mis-measured. + engine.ShadowObjects.Register( + entityId: EntityId, + gfxObjId: GfxObjId, + worldPos: PartOrigin, + rotation: Quaternion.Identity, + radius: RootSphereRadius, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + cylHeight: 0f, + scale: 1.0f, + state: 0x1u, // STATIC_PS + flags: EntityCollisionFlags.None); + + return engine; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs index 0bc8d4d8..b8e9daf9 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs @@ -583,19 +583,19 @@ public sealed class Issue337NeftetRockGeometryInspectionTests } /// - /// #337 REPRODUCER — currently FAILING, hence skipped. + /// #337 EVIDENCE — the installed-DAT measurement that condemned the + /// query-site broadphase, pinned so the diagnosis stays checkable. /// /// - /// The per-object broadphase in - /// Transition.FindObjCollisionsInCell (TransitionTypes.cs, the - /// maxReach test) measures the mover's distance to the shadow - /// entry's Position — the part ORIGIN — and compares it against - /// obj.Radius, which is the physics-BSP ROOT BOUNDING SPHERE's - /// radius. For this rock those two are 23.6 m apart, so a mover standing - /// on its plateau is inside the bounding sphere by ~20 m of margin and - /// still fails the test. It is the same defect AP-156 fixed in the flood + /// The deleted per-object filter in + /// Transition.FindObjCollisionsInCell measured the mover's distance + /// to the shadow entry's Position — the part ORIGIN — and compared + /// it against obj.Radius, which is the physics-BSP ROOT BOUNDING + /// SPHERE's radius. For this rock those two points are 23.6 m apart, so a + /// mover standing on its plateau is inside the bounding sphere by ~20 m of + /// margin and still failed the test. Same defect AP-156 fixed in the flood /// and #334 fixed in the registration extent walk, left in place at the - /// query site (filed as #333). + /// query site — filed as #333. /// /// /// @@ -606,14 +606,24 @@ public sealed class Issue337NeftetRockGeometryInspectionTests /// verified instruction-by-instruction against the PDB-paired /// v11.4186 binary. Neither contains a compare or any float math. The /// only spatial rejection retail performs is the BSP node bounding-sphere - /// test inside the walk, which is correctly centred. + /// test inside the walk, which is correctly centred. #333 therefore + /// deleted the filter outright rather than re-centring it. /// /// - /// Un-skip this as the acceptance gate for the fix. + /// + /// This test asserts the DATA, not the production predicate — the + /// production gate is + /// Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover, + /// which drives ResolveWithTransition end-to-end. Both halves must + /// hold for the diagnosis to be the one recorded: the origin-measured + /// distance OUTSIDE the old budget, the centre-measured distance + /// comfortably INSIDE the same radius. If a future DAT or transform change + /// makes either false, the recorded mechanism no longer describes this + /// object and the research note needs revisiting. + /// /// - [Fact(Skip = "#337: fails until the query-site broadphase measures to the " - + "BSP bounding-sphere centre (or is removed, as retail has none).")] - public void TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn() + [Fact] + public void TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn() { string? datDir = ConformanceDats.ResolveDatDir(); if (datDir is null) return; @@ -628,19 +638,27 @@ public sealed class Issue337NeftetRockGeometryInspectionTests const float SphereRadius = 0.48f; const float Movement = 0.308f; // the live step length - float ownerRadius = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Radius; + var root = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere; + float ownerRadius = root.Radius; - // Verbatim from the production predicate. + // Verbatim from the deleted predicate. float distToOrigin = (currPos - rock.Position).Length(); float maxReach = SphereRadius + ownerRadius + Movement + 2f; Assert.True( - distToOrigin <= maxReach, - $"broadphase rejected a candidate the mover is standing on: " - + $"distToOrigin={distToOrigin:F3} > maxReach={maxReach:F3}. " - + $"Measured to the BSP bounding-sphere CENTRE it is " - + $"{(currPos - (rock.Position + Vector3.Transform(rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Origin, rock.Rotation))).Length():F3} m, " - + $"comfortably inside the same radius."); + distToOrigin > maxReach, + $"the old filter is supposed to have REJECTED this candidate: " + + $"distToOrigin={distToOrigin:F3} vs maxReach={maxReach:F3}."); + + // What retail's BSP node test measures instead. + Vector3 centre = rock.Position + + Vector3.Transform(root.Origin, rock.Rotation); + float distToCentre = (currPos - centre).Length(); + + Assert.True( + distToCentre <= ownerRadius, + $"the mover must be inside the root bounding SPHERE it was standing " + + $"on: distToCentre={distToCentre:F3} > radius={ownerRadius:F3}."); } private static Placed ResolveOwner(