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:
parent
49a7e90652
commit
5a1eeace73
3 changed files with 1182 additions and 2 deletions
232
docs/research/2026-08-06-337-neftet-wedge-mechanism.md
Normal file
232
docs/research/2026-08-06-337-neftet-wedge-mechanism.md
Normal 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 |
|
||||
|---|---|---|---|
|
||||
| …218906–219296 | 51.096 | 51.081 | standing correctly (delta +0.015). **Every horizontal move of 0.25–0.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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue