docs(physics): #337 diagnosed — it is #333's query-site broadphase, not the mesh

Report-only. No production code changed.

The collision mesh is present, correctly shaped, correctly placed in the
world, and the BSP traversal reaches every part of it. The mover never gets
as far as the query. FindObjCollisionsInCell's per-object broadphase 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 0xC8766009 those two points are 23.556 m apart, so a
mover standing on its plateau is inside the real bounding sphere by ~20 m of
margin and is still rejected. Same defect AP-156 fixed in the flood and #334
fixed in the registration extent walk, left in place at the query site.

Measured, not inferred. An offline replay against the installed DAT
reconstructs all eleven landblock-0x8766 owners and matches the live [geom]
placement exactly (0xC8766002 at (84.699,100.082,13.000) yaw -45.00 vs the
log's objPos + bspCentreOffset). At the position the client fell through, the
production swept query returns a hit on poly 31 at 0.037-0.366 m while the
filter rejects the candidate: distToOrigin=60.434 > maxReach=59.697, distance
to the bounding-sphere CENTRE 37.083 m against a 56.909 m radius. The live
capture recorded that rejection 7,225 times with the probe's own
wouldAcceptAtCenter=True on every one.

Bounded because the dead zone is the shell between maxReach and the true
sphere, up to ~23.5 m thick on the far side. movement.Length() is a budget
term: a 0.25 m walking step gives shortfall +0.60, a 0.72 m step +0.14, and
~0.86 m passes — which is exactly why jumping over the spot works, walking
into it does not, and a corpse falls through.

Three hypotheses refuted by measurement, not by argument:

- "the rock's own mesh never collides" — true of 0xC8766002 and it is
  INNOCENT; its geometry is 22.8 m from the wedge and it has zero brute-force
  hits over a 12,493-point lattice covering the plateau. It is a candidate
  only because it is a 130x147 m owner. The rock actually walked on is
  0xC8766009.
- wrong world transform — the offline placement reproduces the runtime
  exactly, and a uniform displacement cannot produce a bounded pocket.
- BSP traversal hole — a referee ran the production walk against brute force
  at 7,770 on-surface probes across all eleven owners plus 137,423 lattice
  points. Mismatch 0 everywhere. A 0.5 m hole map also shows continuous
  upward-facing coverage across the whole wedge region.

[geom]'s verdict=coincident was never able to decide this: LogGeometry
compares the physics box against the visual box in the object's OWN LOCAL
FRAME, so it proves shape agreement and says nothing about world placement.
Recorded in the doc so the next reader does not re-trust it.

Retail has no per-object distance filter on the BSP branch. Verified
instruction-by-instruction with cdb against the PDB-paired v11.4186 binary:
CPartArray::FindObjCollisions @0x00518180 is 14 instructions of bare
do/while over parts[i]; CPhysicsPart::find_obj_collisions @0x0050d8d0 is 17
instructions of two null checks plus the call to CGfxObj::find_obj_collisions
@0x00534700. No compare, no float math in either. The in-tree comment calling
the filter a retail analog and response-neutral is wrong on both counts.

The support=object cpNz=1.0000 readings inside the rock are not the rock:
ValidateTransition:6076 is retail's stationary-fall failsafe manufacturing a
flat plane through the sphere bottom, and :5997 is the LastKnownContactPlane
restore holding a stale plane. Both are retail-correct responses to a stuck
body, and they are why the client believes it is standing while ACE rejects
the position.

Preferred fix is to delete the pre-check for BSP entries and correct the
comment; fallback is to measure to the bounding-sphere centre, which also
needs BoundsCenter carried on ShadowEntry. Neither is landed.

The reproducer was confirmed to FAIL when un-skipped, with the numbers above
— this campaign has caught eleven green tests covering nothing, so a fixture
that cannot distinguish the bug is worse than none.

Gates: bin/obj deleted, Release build 0 errors, Core suite 4,287 passed /
2 skipped / 0 failed (baseline 4,286/1 plus three new dumps and the one
deliberately skipped reproducer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 21:16:31 +02:00
parent 49a7e90652
commit 5a1eeace73
3 changed files with 1182 additions and 2 deletions

View file

@ -0,0 +1,232 @@
# #337 — the Neftet plateau wedge: mechanism, measured
**Date:** 2026-08-06
**Status:** mechanism proven offline; fix proposed, NOT landed.
**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
(`b52967de`, flood sphere placement).
---
## Verdict in one paragraph
The collision mesh is present, correctly shaped, correctly placed in the
world, and the BSP traversal reaches every part of it. **The mover never gets
as far as the query.** `Transition.FindObjCollisionsInCell`'s per-object
broadphase 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 points
are 23.6 m apart, so a mover standing on the plateau is inside the real
bounding sphere by ~20 m of margin and is still rejected. Retail has no such
filter at all. Everything else in the symptom set — the wedge, the sink, the
corpse fall-through, ACE's spawn refusal, the 16,278 m/s rejection, the
character vanishing from a retail observer's view — is downstream of that one
rejection.
---
## What was refuted, and by what measurement
### 1. "The rock's own mesh never collides" — TRUE for `0xC8766002`, and it is INNOCENT
`0xC8766002` / `gfx=0x010046DE` really does report 11,014 `tested-ok` with
zero `tested-adjusted`, `tested-collided` or `tested-slid` across the whole
capture. That is correct behaviour: **its geometry is 22.8 m away from the
wedge point.** Over a 30 × 30 × 12 m lattice covering the whole plateau
(12,493 points) it has **zero** brute-force hits — it simply is not there.
It is a candidate in that cell only because it is a 130 × 147 m owner whose
registration legitimately spans the cell. The probe families that reach it are
working exactly as designed. This object was a red herring.
The neighbours do collide: `0xC8766003` 767 adjusted / 26 collided,
`0xC8766009` 1,135 adjusted / 708 slid / 58 collided. The rock the player
actually walks on is **`0xC8766009` / `gfx=0x01004751`**.
### 2. Wrong world transform — REFUTED
The offline reconstruction from the installed DAT reproduces the runtime
placement exactly: `0xC8766002` at `(84.699, 100.082, 13.000)` yaw 45.00°
against the live `[geom]` line's `objPos=(84.70,100.08,13.00)` and its
`bspCentreOffset` (which implies 45.00°). All eleven owners match. The
transform is right.
### 3. BSP traversal hole / bounding-sphere early-out — REFUTED
A referee ran the production walk (`FlatBspQuery.SphereIntersectsPoly`,
node bounding-sphere early-outs and all) against brute force over every
polygon the tree indexes, at 7,770 probe points placed on both faces of every
polygon of all eleven owners, plus 137,423 lattice points over the plateau.
**Mismatch = 0 everywhere.** There is no pocket the traversal cannot reach.
### 4. Missing / degenerate geometry, a hole in the walking surface — REFUTED
A 0.5 m hole map over the plateau shows continuous upward-facing physics
coverage across the whole wedge region; the only gaps are outside the rock's
footprint. At the wedge XY the column is closed: an up-facing polygon at
z = 50.985 (nz = +0.9805) over a down-facing one at z = 40.597 (nz = 0.9325),
i.e. 10.4 m of solid rock, with the player's feet at z = 50.011 — **0.975 m
inside it.**
### 5. `[geom] verdict=coincident` — confirmed to mean less than it looks
`LogGeometry` compares `physicsMin/Max` against `visualMin/Max`, both taken
from the same GfxObj asset in the object's **own local frame**. No world
transform enters the comparison. `coincident` proves shape agreement only.
It happens to be true here, but it could never have been the discriminator.
---
## The mechanism, measured
### The frame-by-frame (337-support.log, cell `0x8766002B`)
Landblock-local coordinates; the log's Y carries a +576 m live-centre offset,
removed here. Player feet position; Setup `0x02000001` gives sphere[0] at
feet + 0.475 with r = 0.480 and sphere[1] at feet + 1.350.
| t | feet z | surface z at that XY | what happened |
|---|---|---|---|
| …218906219296 | 51.096 | 51.081 | standing correctly (delta +0.015). **Every horizontal move of 0.250.53 m returns `moved=0.000, stalled=True`** on an 11° walkable slope. This is the wedge. |
| 219328 | 51.096 → 51.389 | | player jumps to escape |
| …219937 | → 54.638 | | free rise, `support=none` |
| …221125 | 54.638 → 50.021 | 51.183 | **falls straight through the surface.** Six consecutive resolves accept the full commanded step with no contact plane. |
| 221156 | 50.021 | | descent finally blocked, still no contact plane |
| 221671 | 49.908 | 51.127 | `cpSrc=ValidateTransition:6076` — retail's stationary-fall failsafe **manufactures** a flat plane at z = 49.903, 1.2 m below the real rock surface |
The `support=object cpNz=1.0000` readings inside the rock are not the rock.
`ValidateTransition:6076` is retail's `FramesStationaryFall > 1` synthetic
up-plane through the sphere bottom; `ValidateTransition:5997` is retail's
`LastKnownContactPlane` restore, i.e. a **stale** plane retained from frames
when the object was still admitted. Both are retail-correct responses to a
stuck body — which is why the client believes it is standing while ACE
believes the position is invalid.
### The replay: the query would have hit
`Issue337NeftetRockGeometryInspectionTests.ReplayTheDescentThatFellThrough…`
runs each of those six descent steps as a swept sphere against `0xC8766009`'s
real BSP:
```
CONTROL climb step ent=0xC8766009 sphere[0] swept=True(poly 22) static=True nearest=0.070 m
CONTROL stand step ent=0xC8766009 sphere[0] swept=True(poly 26) static=True nearest=0.003 m
FALL 2 51.269->50.961 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.248 m
FALL 3 50.961->50.670 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.037 m
FALL 4 50.670->50.335 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.366 m
```
The geometry is right there and the production primitive returns the hit.
Path 6 of `FlatBspQuery.FindCollisionsCore` (the airborne dispatch) would have
called `path.SetCollide(...)` and returned `Adjusted`, blocking the fall.
**It was never called.**
### Why it was never called
`Transition.FindObjCollisionsInCell` (`src/AcDream.Core/Physics/TransitionTypes.cs`,
the `maxReach` test in the candidate loop):
```csharp
Vector3 deltaToCurr = currPos - obj.Position; // ← part ORIGIN
...
float maxReach = sphereRadius + obj.Radius // ← BSP ROOT SPHERE radius
+ movement.Length() + 2f;
if (distToCurr > maxReach) continue;
```
For `0xC8766009`: origin `(159.107, 36.629, 0.005)`, root bounding sphere
centred at local `(1.753, 14.259, 18.667)` with radius 56.909 — i.e. the
sphere centre sits **23.556 m** from the origin the filter measures to.
At the fall position, measured (reproducer output):
```
distToOrigin = 60.434 m > maxReach = 59.697 m → REJECTED
distance to the BSP bounding-sphere CENTRE = 37.083 m ≪ 56.909 m radius
```
The live capture recorded the same thing 7,225 times for this owner, and the
probe's own `wouldAcceptAtCenter` column says `True` on **every** rejection:
```
currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=59.64 shortfall=+0.60 acceptAtCentre=True move=0.255
currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=60.10 shortfall=+0.14 acceptAtCentre=True move=0.715
```
### Why it is bounded, and why jumping over it works
The dead zone is the shell between `distOrigin = maxReach` and the true
bounding sphere. Because the sphere centre is offset 23.556 m from the origin,
that shell is up to ~23.5 m thick on the far side of the object — here it
covers the top of the plateau and nothing else. Everywhere closer to the
origin, the same mesh collides normally. That is the "fails in a bounded
region, works elsewhere on the same object" property.
`movement.Length()` is a term in the budget. A walking step of 0.25 m gives
shortfall +0.60 (rejected); a step of 0.72 m gives +0.14; a step of ~0.86 m
passes. **A jump's larger per-frame movement inflates the budget and lets the
object back through the filter.** That is why jumping over the spot works and
walking into it does not, and why a corpse — small per-frame movement — falls
straight through.
---
## Retail
There is no per-object distance filter on retail's BSP branch. Verified
instruction-by-instruction with cdb against the PDB-paired v11.4186 binary
(`C:\Users\erikn\Downloads\acclient.exe`, `check_exe_pdb.py``MATCH`,
GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`):
- `acclient!CPartArray::FindObjCollisions` @ **0x00518180** — 14 instructions:
a bare `do/while` over `parts[i]` calling `CPhysicsPart::find_obj_collisions`
and breaking on `!= OK`. No compare, no float math.
- `acclient!CPhysicsPart::find_obj_collisions` @ **0x0050d8d0** — 17
instructions: null-check `gfxobj`, null-check `gfxobj->physics_bsp`
(`[ecx+78h]`), `SPHEREPATH::cache_localspace_sphere`,
`CGfxObj::find_obj_collisions` @ 0x00534700. No compare, no float math.
`CPhysicsObj::FindObjCollisions` @ 0x0050f050 reaches that pair through
`CPartArray::FindObjCollisions` at 0x0050f192; its cylsphere/sphere loops
(0x0050f1c4 / 0x0050f251) call the real `intersects_sphere` per primitive —
they are tests, not pre-filters. Retail's only spatial rejection is the BSP
node bounding-sphere test inside the walk, which is correctly centred.
The in-tree comment claiming the filter is "the analog of the part
sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions`
response-neutral, pure perf" is **wrong on both counts.**
---
## Proposed fix
**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.
**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.
Acceptance gate: un-skip
`Issue337NeftetRockGeometryInspectionTests.TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`.
Verified to fail today with the numbers above.
---
## Separate observation, not part of this defect
Setup `0x02000001` authors `StepUpHeight = 0.600` and
`StepDownHeight = 1.500`. The live `[support]` lines show the player resolving
with `stepUp=0.400 stepDown=0.400`. A 1.5 m step-down is what keeps a mover
attached to a descending slope; 0.4 m is not. Worth its own investigation —
it does not cause this wedge, and it was not chased here.