merge: #333/#334 Neftet collision — the query-site broadphase is deleted
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Closes #333, #334 and #337. The Neftet plateau defect — wedged at the
top, jumps sinking into the mesh, corpses falling through — was one
mechanism presenting as three symptoms: Transition.FindObjCollisionsInCell
measured the mover's distance to a shadow entry's part ORIGIN and compared
it against the BSP ROOT BOUNDING SPHERE's radius, two points 23.556 m apart
for that rock. Geometry deep inside the real sphere was discarded before
BSPQuery ever ran.

Deleted rather than re-centred: retail has no distance pre-filter at all,
disassembled from the PDB-paired binary rather than read from Binary Ninja.
Cell membership is retail's broad phase.

Also lands #334's outdoor extent walk (retail's find_bbox_cell_list),
retires AP-158, files #338, and opens Campaign S for the twelve remaining
collision-domain items — now the active work ahead of the M4 vendor
slices per user direction that physics and collision come first.

The ACDREAM_PROBE_SUPPORT / ACDREAM_WIRE_MESH / ACDREAM_PROBE_REACH probe
families are deliberately retained: C5c's connected gates still owe the
instrumentation they provide.

Gate: clean-room full solution suite at a0690947 with all 43 bin/obj
directories deleted first — 11,231 passed / 4 skipped / 0 failed across
all nine projects. User accepted the fix in live play at Neftet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 22:40:23 +02:00
commit 0ce54a5c4a
32 changed files with 5937 additions and 160 deletions

View file

@ -1475,6 +1475,29 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the
collision geometry where the visual geometry is?** (#337, TEMPORARY).
`[support]`: one line per resolve **for every body, not just the player**
(a corpse falling through geometry is the cheapest control there is on
"movement code vs geometry data"). It samples the outdoor terrain
independently at the body's own out-XY and prints the contact plane's own
height at that same XY, so `support=terrain` / `object` / `none` is a
measurement rather than an inference; `cpSrc=` names the site that wrote
the plane so provenance cross-checks the classification. Edge-eager,
throttled to 4 Hz per body, and emits every 10 cm of vertical movement.
`[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex
cloud against its visual mesh AABB in the same frame, with a verdict
(`coincident` REFUTES "collision isn't where the visual is";
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate
those cases — it carries no plane normal, no plane height, no terrain
sample and no provenance.
- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from
a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan)
beside the same objects' visual mesh boxes (magenta) and the terrain
surface (yellow). Settles "visual versus collision" by eye instead of by
log. `ACDREAM_WIRE_RADIUS=<metres>` sets the window (default 30).
TEMPORARY, with the #337 probe family.
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
JSON Lines record with full inputs, PhysicsBody snapshot before AND

View file

@ -24,10 +24,223 @@ 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.
## #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
## #338 — The player resolves with stepUp/stepDown 0.400 where Setup 0x02000001 authors 0.600 / 1.500
**Status:** OPEN
**Severity:** high for the tall-prop population. It is the gate immediately
**Severity:** unknown until measured, plausibly medium. A 1.5 m step-down is
what keeps a mover attached to a descending slope; 0.4 m is not, so this is a
candidate contributor to descent/edge feel — but that link is NOT established
and must not be assumed.
**Filed:** 2026-08-06, spotted in the #337 `[support]` capture while chasing a
different defect. Deliberately not chased there: it does not cause the Neftet
wedge, and folding it in would have made that fix unfalsifiable.
**Component:** physics / movement.
### The observation
The human Setup `0x02000001` authors `StepUpHeight = 0.600` and
`StepDownHeight = 1.500`. The live `[support]` probe lines show the player
resolving with `stepUp=0.400 stepDown=0.400`.
### What is NOT yet established
- Whether the authored Setup values are what retail feeds `CPhysicsObj`, or
whether retail also substitutes constants at this seam. **Grep
`named-retail` for `get_stepup_height` / `get_stepdown_height` and their
callers before touching anything** — the authored DAT field being ignored is
only a defect if retail reads it.
- Where the 0.400 comes from: a hardcoded default, a stance/posture-dependent
value, or a value that never got wired from the Setup.
- Whether it has any observable consequence. Do not open this by reasoning
from the source; the #337 lineage already burned two diagnoses that way.
### Related
Sits next to #32 (local-player cliff edge-slide), which has its own research
at `38db9fff` and needs a live `ACDREAM_DUMP_EDGE_SLIDE=1` capture before a
fix. If both turn out to touch descent feel, do NOT bundle them — they have
different mechanisms and need separate gates.
---
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance
**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
rejected before the query it would have passed. Retail has no such filter
(`CPartArray::FindObjCollisions` @0x00518180 and
`CPhysicsPart::find_obj_collisions` @0x0050d8d0 verified instruction-by-
instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014
`tested-ok` and zero hits, is **innocent** — its geometry is 22.8 m away.
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`.
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
the wrong-transform and BSP-traversal-hole hypotheses are refuted by
measurement.
**Severity:** HIGH — walk-through, fall-through, and a hard movement stop on world geometry.
**Filed:** 2026-08-06, user-reported in live play after #334's fix landed.
**Component:** physics / collision — possibly geometry data rather than movement code.
### Symptoms, all from the user in live play
1. Walks **up** a rock face onto a plateau fine, then **cannot pass at the top** — wedged, position frozen.
2. Jumping is **"swallowed half way by the rock"** — the body sinks into the visual geometry.
3. **A monster corpse falls straight through the rock.**
Symptom 3 is the load-bearing one. A corpse is a plain physics body with no
player-specific movement logic, so a fall-through there cannot be explained by
anything in the player's controller.
### What is already RULED OUT
`ACDREAM_PROBE_REACH` (`334-fix-gate.log`, and the earlier
`334-neftet-probe.log`). At the frozen position: `blocked=0`, and **every**
candidate returns `tested-ok` — including the landblock's own rock mesh
`gfx=0x010046DE` in cell `0x8766002B`. **No object is blocking the player.**
That probe can only see shadow objects, so it has ruled out its own domain and
can say nothing about terrain or the transition.
Two diagnoses have already been refuted by measurement on this defect's
lineage: the broadphase reach filter (#333/AP-158) and the edge-slide family.
Do not open a third by reasoning from the source.
### The remaining candidates — and what is NOT yet established
- **(a) terrain** is what supports/blocks the body (walkable slope limit,
step-up refusal, terrain Z).
- **(b)** a **collision mesh placed somewhere other than its visual**, so the
body interacts with geometry that is not where the rock is drawn.
- **(c)** the **transition wedging** despite an unobstructed path.
(b) is the current working hypothesis and is **NOT ESTABLISHED**. It is
plausible — a corpse falling through and a jump sinking in are both what
absent-or-displaced collision looks like — but no measurement supports it yet,
and the instruments below were built to REFUTE it, not to confirm it.
Possibly relevant, possibly coincidence: landblock `0x8766` carries the
**largest single collision owner in the game**, an 81-cell (9×9) footprint
measured during #334 — larger than anything else by a wide margin.
### Instruments (2026-08-06 — TEMPORARY, strip with the physics-probe family)
`ACDREAM_PROBE_RESOLVE` alone does **not** separate (a), (b) and (c): it prints
a three-value contact-plane token, no plane normal, no plane height, no terrain
sample and no plane provenance, so all three candidates produce the same line.
Two additions close that:
- **`ACDREAM_PROBE_SUPPORT=1`** → `[support]` + `[geom]`.
- `[support]`, one per resolve **per body** (players AND corpses): samples the
outdoor terrain independently at the body's own out-XY and prints the
contact plane's own height at that same XY. `support=terrain` /
`support=object` / `support=none` is then a measurement, not an inference,
and `cpSrc=` names the code site that wrote the plane so provenance and
classification cross-check each other.
- `[geom]`, once per GfxObj that comes near the mover: compares the object's
physics-BSP vertex cloud against its visual mesh AABB in the same local
frame. `verdict=coincident` **refutes (b)** for that object outright;
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
each name a specific data defect.
- **`ACDREAM_WIRE_MESH=1`** upgrades the existing F2 collision overlay from a
broadphase proxy cylinder to the objects' real physics-BSP polygon edges
(cyan) beside their visual mesh boxes (magenta) and the terrain surface
(yellow). Settles "visual versus collision" by eye.
### How to read the capture
| Observation | What it means |
|---|---|
| `[geom] verdict=no-physics-bsp` or `empty-physics-bsp` on the rock | The rock has **no collision geometry**. All three symptoms follow; nothing on the movement side needs explaining. |
| `[geom] verdict=displaced` | **(b) confirmed.** Fix the placement/registration transform. |
| `[geom] verdict=coincident` on every nearby object | **(b) refuted.** The cause is (a) or (c); read `[support]`. |
| `[support] support=terrain` while standing on the visible plateau | (a): terrain, not the rock, is the support — terrain Z near the plateau top is the thing to look at. |
| `[support] support=object` with `cpAboveTerr` ≈ the plateau height | The rock IS supporting the body; the wedge is (c). |
| `[support] stalled=true ok=true` with `cpWalkable=true` | (c): the transition accepts the move and advances nothing. |
| `[support] support=none` on the corpse throughout its fall | Nothing ever contacts it — consistent with absent collision, and `[geom]` says whose. |
| **No `[support]` line at all** for the corpse's guid while it visibly falls | The client is not simulating that body — the descent is server-driven or presentational, and the client-side collision path is not the place to look. An absence here is a real answer, not a gap in the capture. |
| `[support] cpWalkable=false` at the freeze | Slope-limit refusal — compare `cpNz` against `floorZ` on the same line. |
## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX
**Status:** OPEN
**Severity:** low. Over-inclusive only — extra broadphase candidates indoors, never a missed one. The opposite direction (the outdoor half) was #334 and is closed.
**Filed:** 2026-08-06, at the #334 fix.
**Component:** physics / cell membership
**Register row:** AP-159.
#334 ported `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 and the OUTDOOR arm of the part-array `find_transit_cells` it dispatches to (`CLandCell::find_transit_cells` @0x00533840`add_all_outside_cells` @0x00533360`add_cell_block` @0x005331d0). The INDOOR arm of that same dispatch is still acdream's sphere traversal.
**What retail does** (`CEnvCell::find_transit_cells` @0x0052cae0, disassembled from the PDB-paired 2013-09-06 binary), per portal × per part:
1. cheap reject: the part's `CGfxObj::physics_sphere` centre through `Position::localtolocal` (`0x0052cb5a`), tested against the portal plane with `eps = F_EPSILON + radius` (`0x0052cb65`);
2. on pass, the ADMITTING test is box-vs-plane: `CPhysicsPart::GetBoundingBox` @0x0050d600 (`0x0052cbdd`) → `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) → `Plane::intersect_box` @0x005aa170 (`0x0052cc05`);
3. if the side differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` (`0x0052cc1e`) sets the “leads outside” flag; otherwise `CCellPortal::GetOtherCell` @0x0053ba30 (`0x0052cc2b`) — a THISCALL on the portal record (`ecx` set at `0x0052cc18`) taking ONE explicit argument, `cellarray->do_not_load_cells` (`0x0052cc27 mov eax,[edi+4]`; the CELLARRAY layout is +0 `added_outside`, +4 `do_not_load_cells`, +8 `num_cells`, +0xc `cells`, cross-checked against `find_bbox_cell_list`'s `0x00510fc8`/`0x00510fcf` zeroing and `add_all_outside_cells`' `0x0053336c` read of `[arg3]`). That RESOLVES the #334 contract's open question 11.4 in the AFFIRMATIVE — the flag IS threaded through, as the single explicit argument, not omitted. Then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910`BSPTREE` @0x0053c880 gates the add (`0x0052cc5a`);
4. after all portals, the outside flag runs `add_all_outside_cells` (`0x0052ccea`).
**What acdream does:** `CellTransit.BuildShadowCellSetFromParts`'s indoor arm calls `FindTransitCellsSphere` with the per-part BSP root spheres (`ShadowObjectRegistry.BuildBspPartSpheres`), i.e. step 1's cheap reject used as the admitting test. Same for the outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0).
**Why it was deferred rather than folded into #334:** closing it needs a BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations, plus their exact referee — a separately gateable change with no bearing on #334's outdoor defect, and one that no #334 gate would exercise. Adding ~150 lines of unverified geometry under a green-but-uncovering test is the failure mode this campaign has now hit ten times.
**Files:** `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm, `FindTransitCellsSphere`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`).
---
## #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:** 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.
@ -69,7 +282,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
@ -1741,6 +1959,28 @@ it. Do #297 FIRST — #298 depends on it.
the radar, same defect class, same fix shape (mirror the property into the
bitfield/snapshot at its source). Filed from the #297 delta review.
- **#336 — OPEN — `RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` is a FOURTH, load-sensitive flake — distinct from #302, #308 and #321. LOW.**
`tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2381` asserts
`GC.GetAllocatedBytesForCurrentThread()` is EXACTLY 0 across a warmed 10,000-iteration
steady-contact refresh loop. Observed failing once on 2026-08-06 inside a full-solution
`dotnet test AcDream.slnx -c Release -m:1` run (measured 2,944 bytes), then passing on the
immediate full-suite retry, on both of two isolated `AcDream.Runtime.Tests` project runs,
and on a filtered single-test run.
**Filed rather than absorbed, and deliberately NOT conflated with the other three.**
It shares #302's MECHANISM (an exact `GC.GetAllocatedBytesForCurrentThread()` assertion,
sensitive to JIT tiering and background GC on the measuring thread) but it is a different
test in a different assembly — #302 is `AcDream.App.Tests`, this is
`AcDream.Runtime.Tests` — so “the known allocation flake” would hide whichever of the two
is real on any given run. #308 is a wall-clock deadline in `AcDream.Core.Net.Tests`;
#321 is a concurrent-decode dedup in the sound cache. Four mechanisms, four rows.
**Not caused by #334's cell-membership port,** which is the change in flight when it was
seen: the measured loop calls only `RuntimeCollisionReportingState` handling, registers no
shadow inside the measurement, and touches none of `CellTransit` / `ShadowObjectRegistry` /
`ShadowShape`. Fix shape, same as #302: warm the path before measuring, or assert a bounded
range rather than an exact zero — matching how the other allocation gates in the repo are
written. Do not delete the assertion; the 0 B/resolve budget it guards is a real Slice I1
invariant.
- **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`
is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of
`AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on
@ -10556,7 +10796,19 @@ missing is the plugin-API surface.
## #334 — Large static formations lose collision at their boundaries (Neftet)
**Status:** OPEN
**Status:** DONE (2026-08-06) — retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path is ported. A physics-BSP object's outdoor cell membership is now the FILLED land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans, crossing landblock boundaries freely, instead of the fixed 3×3 sphere neighbourhood. Awaiting the user's live gate at the same Neftet formations.
**Fix.** `CellTransit.BuildShadowCellSetFromParts` + `CellTransit.AddAllOutsideCellsFromParts` (`CLandCell::add_all_outside_cells` @0x00533360 + `add_cell_block` @0x005331d0, disassembled from the PDB-paired 2013-09-06 binary — Binary Ninja mis-renders four separate constructs inside that one function); `ShadowPartGeometry` / `ShadowPartBox` carry the BSP root sphere AND the authored box as one value; `ShadowObjectRegistry.RegisterMultiPart` dispatches on `HAS_PHYSICS_BSP_PS` exactly as retail does at `0x00515285`, and `BuildFloodSpheres`' BSP arm is deleted rather than left unreachable. Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read “extra broadphase candidates, never a missed one” — #334 is a missed one); AP-159 (indoor part-array overload, issue #335) and AD-49 (seed-time rectangle) filed.
**Cost, measured over the installed DATs BEFORE any code was written** (1,258 physics-BSP GfxObjs with vertices): cells/object p50 = 4, p90 = 4, p99 = 12, max 49 (7×7). The port is CHEAPER than the old 3×3 = 9 for 98.97% of them — the crossover is exact, any object under 24 m of XY extent yields at most 2×2. Row totals (`shapes × cells`) over all 1,031 landblocks carrying BSP owners fall from 97,173 to 15,607 (0.161×); dense Arwic 0xC6A9 falls 342 → 43 (0.126×). Exactly ONE landblock more than doubles (0x8964, 45 → 112 rows, 2.489×). The worst single-owner rectangle in the whole world is 81 cells (9×9) in 0x8766 — above the 7×7 bound predicted from root-sphere statistics, because that bound assumed the BSP root sphere bounds the whole vertex array and it bounds only the physics polygons' subset.
**Precondition confirmed before any expected cell set was pinned:** `0x010046D8`'s authored box is 96 m × 96 m about a part origin at block-local (63.78, 56.29) — cell (2,2) = `0x87640013`, which independently corroborates the 3×3-centred-at-`0x87640013` diagnosis derived from the live probe. Its rectangle spans cell columns 0..4 on both axes and DOES contain `0x87640011` and `0x87640019`, the two cells the probe measured empty.
**Gate note (AP-158 / #333):** the fix is necessary and not sufficient in general. A player at the far corner of a large new rectangle can still be discarded by the broadphase reach filter, which measures from the part origin. The live gate FAILS if `inCell` rises while `rejectedReach` rises with it; the remedy for that is #333, not a wider budget here.
Original finding below.
**Status (at filing):** OPEN
**Severity:** HIGH — walk-through and fall-through on world geometry.
**Filed:** 2026-08-06, user-reported in live play.
**Component:** physics / collision / broadphase
@ -10575,7 +10827,58 @@ did **not** resolve it either, which is itself a diagnostic clue (see below).
formations, and no floor above it — jumping over lands you inside/through.
"Solid near the middle, absent at the edges" is the shape to reason from.
### Leading candidate — AP-158 / #333, the broadphase reach filter
### MEASURED IN GAME 2026-08-06 — cause found, and it is NOT the reach filter
**The reach-filter theory is REFUTED.** A live probe (`ACDREAM_PROBE_REACH`,
commit `b61f5fd4`) was run with the user standing in front of, and then inside,
a Neftet formation. Evidence: `334-neftet-probe.log`, 8,401 lines.
Standing inside the formation the collision system reported:
```
[reach-q] cell=0x87640019 inCell=2 exempt=2 reached=0 rejectedReach=0 tested=0 blocked=0
```
**Two candidates, both the player's own body spheres.** Nothing was rejected
because there was nothing to reject — the formation is not in the collision
candidate set at all. `rejectedReach=0` kills AP-158 as the cause here.
**The blocking part the user found is the discriminator.** One object does
collide: `gfx=0x010046D8`, a BSP with `objR=69.471` — the landblock's baked
rock geometry, one object spanning a large area. Its coverage:
| Cell | Rock object present? |
|---|---|
| `0x8764000A`, `0x87640012` | **YES**`tested-collided` / `tested-slid`, player blocked |
| `0x87640011`, `0x87640019`, `0x87630018` | **NO**`inCell=2`, both entries the player |
**Same object, same landblock, present in some cells and absent from directly
adjacent ones.** `0x87640012` is cell index 17 → grid (2,1); `0x87640011` is
index 16 → grid (2,0), immediately beside it and empty.
### Cause: a landblock-spanning object is registered by a single bounding sphere
`ShadowObjectRegistry.BuildFloodSpheres` derives cell membership from one
sphere per part. This object's own radius is **69.471 m** while a landblock is
**192 m** across, so a single sphere cannot geometrically reach every cell the
mesh occupies. Cells beyond its reach receive no registration and the player
walks through.
Retail does not use a sphere here. `CPhysicsObj::calc_cross_cells` @`0x00515230`
routes BSP-bearing objects to `find_bbox_cell_list` @`0x00510fc0`
`calc_cross_cells_static` @`0x00518160`, i.e. a walk over the object's
**extent**, not a single enclosing sphere.
**Relationship to AP-156 (`b52967de`), which did not fix this and was not
expected to:** AP-156 moved the flood sphere to the right *place* (it was
centred on the part origin while sized from the geometry). That was necessary
and is confirmed correct — but the sphere is also the wrong *shape* for objects
whose extent exceeds their own radius. AP-156 fixed position; this issue is
about coverage. Sequential, not alternative.
### Superseded — the original leading candidate, retained for provenance
### Superseded detail — AP-158 / #333, the broadphase reach filter
`TransitionTypes.cs:3898`-ish measures `currPos - obj.Position` (the part
**origin**) against `obj.Radius`, admitting a contact only when the geometry

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,247 @@
# Campaign S — collision shape & response fidelity
**Opened:** 2026-08-06, immediately after #333/#337 closed (`ea83b043`).
**Status:** PLANNED, not started.
**Scope:** the twelve remaining collision-domain items — five shape/membership
divergences, three resolution-math divergences, two undecodable-math rows, and
three open bugs.
**SSOT while active:** this file. Digest:
`claude-memory/project_physics_collision_digest.md`.
---
## Why a campaign and not twelve tickets
Three of these rows edit the same two functions. `AP-157` and the `AP-156`
residual both live in `ShadowObjectRegistry.BuildFloodSpheres`; `AP-159`/`#335`
lives one call away in `CellTransit.BuildShadowCellSetFromParts` and in
`ShadowObjectRegistry.BuildBspPartSpheres`. Shipping them as separate tickets
means three review cycles over the same code and three chances to reintroduce
each other's bugs. This project already has a written rule about exactly that
shape of work: **shared-file slices are ONE agent against a pinned contract**
(`feedback_dont_parallelize_coupled_plan_slices`).
The second reason is ordering. **Membership gaps mask query gaps.** We just
watched it happen: AP-156 put geometry into the right cell and AP-158 threw it
away one layer down, so AP-156's entire visible benefit was invisible until
#333 landed. Anything upstream of the query has to be correct before a gate on
the downstream math means anything.
---
## The governing lesson from the last campaign
**The register rows are leads, not specifications. Measure before you fix.**
The evidence, all from the last two weeks:
- **AP-155(b) recorded the flood approximation as OVER-inclusive**, and used
that direction as the reason it was safe to defer. Measured, it was
UNDER-inclusive for 428 of 530 Setups — the opposite, and the dangerous
direction.
- **AP-156's risk column was wrong**, and its wrongness is precisely why #334
— a user-visible loss of collision — sat inside it unnoticed.
- **AP-22 described an unreachable branch.** 0 of 5,935 installed Setups could
satisfy its guard. The correct fix was deletion, not a port.
- **#331's headline claim was refuted outright.** The behaviour was already
retail-faithful.
So one row in four, in this exact domain, was materially wrong about its own
population, direction, or existence. **Every slice below opens with a
measurement that can cancel it.** A slice that measures its population at zero
closes as "row deleted", and that is a success, not a wasted slice.
---
## Slice order
### Pre-work — two cheap unblocks, before the campaign proper
Both are the user's own outstanding reports and both are blocked on a
measurement that costs far less than the fix. Neither is a campaign slice.
**PW-1 — #338, the step heights.** Setup `0x02000001` authors
`StepUpHeight = 0.600` / `StepDownHeight = 1.500`; the client resolves with
`0.400` / `0.400`. **First question is whether retail reads the authored field
at all** — grep `named-retail` for the step-height getters and their callers.
If retail substitutes its own constants, 0.4 is correct and #338 closes as a
non-defect. ~30 minutes. Do not touch code before that answer.
**PW-2 — #32, local-player cliff edge-slide.** This is the *other half of the
original two-bug report* and the thing the user will feel most, so it does not
sit behind six slices. Research is already done (`38db9fff`):
`CollisionInfo.SetContactPlane` latches last-known at all 13 call sites where
retail's `COLLISIONINFO::set_contact_plane` @0x00509d80 — 22 bytes — never
does. Fix is ~2025 lines, mostly deletion, in 2 files. **Blocked on a live
`ACDREAM_DUMP_EDGE_SLIDE=1` capture**: the report's six-row decision table has
three rows that redirect the fix entirely. Needs the user at the client.
---
### S1 — The flood / membership pipeline
**Rows:** AP-157, AP-156 residual, AP-159 / #335.
**Files:** `ShadowObjectRegistry.cs` (`BuildFloodSpheres`, `BuildBspPartSpheres`),
`CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm),
`ShadowShapeBuilder.cs`.
**One agent. Pinned contract. Not parallelised.**
This is the walk-through direction and the largest single win in the list.
- **AP-159 / #335** — indoors we admit an EnvCell neighbour on a *sphere* test
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. Port the part-array overload. This is the last of AP-156's traversal
residual; the outdoor half already closed with #334.
- **AP-157** — retail's third `calc_cross_cells` branch floods from ONE
`CPartArray::GetSortingSphere`; we flood from every Sphere shape. Our
cylinder flood also ignores `CylHeight`.
- **AP-156 residual** — we scale the flood sphere by entity/part scale; retail's
`CEnvCell::find_transit_cells` reads only `CPhysicsPart::pos` and never
`gfxobj_scale`. Note the asymmetry before changing anything: retail's cross-cell
walk is itself under-inclusive for scaled parts and ours is not, so "match
retail" here means **deliberately adopting a retail bug**. That is a decision
to make explicitly with the user, not silently — over-inclusive is safe,
under-inclusive is the walk-through direction.
**Opens with:** an installed-DAT sweep giving each row its true population and
direction, measured against a DAT field that is not the one being fixed (the
non-circular-oracle rule that caught AP-156's identically-zero assertion).
**Gate:** offline differential over installed DATs, plus one live indoor run —
a dungeon with tight rooms and a door.
---
### S2 — Static publication shape fidelity
**Row:** AP-155. **Files:** `LandblockPhysicsPublisher.cs`,
`LandblockPhysicsContentBuilder.cs`.
The static-load paths emit an authored Setup Sphere as a height-capped
Cylinder. Different files from S1, so it is separable — but it must land
**after** S1, because S1's flood consumes what these paths produce, and
measuring S2's effect while S1 is in flight would confound both.
**Gate:** shares S1's live indoor run if S1 and S2 land together; otherwise
offline only, since a shape substitution's population is fully measurable from
the DATs.
---
### S3 — Animated collision pose
**Row:** AP-84. **Files:** `LiveEntityDefaultPoseResolver.cs`,
`LiveEntityCollisionBuilder.cs`, `ShadowShapeBuilder.cs` (`partPoseOverride`).
Server entities with a MotionTableId register their BSP part shapes at the
default style's first-cycle LowFrame pose and never update them; retail uses
the live `CPhysicsPart` pose. A door's collision therefore stays where the shut
door was.
**Why not folded into S1:** different files, different subsystem (animation, not
membership), and it needs a *visual* gate that S1 does not — you have to watch
a door open and then walk through the doorway.
**Gate:** live. Open a door, walk through, close it, walk into it.
---
### S4 — Push-out math
**Rows:** AD-65 + AD-66. **File:** `TransitionTypes.cs` (`AdjustOffset`) — both
in the same function, so one slice.
- **AD-65** — the `collisionAngle > 0` arm substitutes `result -= N * angle`
for retail's `Plane::snap_to_plane`. Recorded effect: downhill XY travel short
by cos²θ (25% at 30°, 50% at 45°).
- **AD-66** — the safety push-out substitutes `radius * ContactPlane.Normal.Z`
for retail's bare `radius`, in both the trigger comparison and the `zDist`
numerator.
**Correction carried in from the closeout:** AD-65 was previously described as
"a live lead for #269". That framing is **retracted**#269 closed 2026-07-31
on the user's own live gate, and AD-65's sign is *opposite* to that symptom.
AD-65 stands on its own merits. (#269's do-not-retry covers friction and jump
chains, which are byte-exonerated; `AdjustOffset` is a different function and
is not covered by it.)
This is feel, not pass-through. It cannot be gated by a test asserting "did I
fall through" — it needs a movement-feel gate.
---
### S5 — The Sledding flatness constant
**Row:** AD-55. **File:** `PhysicsBody.cs` (`calc_friction`).
We compare `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw decomp
literally computes `__fcos(0.17453292519943295)` = cos(10°) ≈ 0.984808. One of
the two is a decode artefact. **Resolve by byte-decoding the constant from the
PDB-paired binary** — there is a documented method for exactly this
(`reference_pe_byte_decode`), and it has already caught one inverted mapping
this project inherited from ACE.
Cheap. **Batch its live gate with S4's** — both are movement feel on slopes,
and asking for two separate slope-feel sessions wastes the only genuinely
scarce resource in this campaign.
---
### S6 — Containment, NOT a fix
**Rows:** AP-83, AP-91.
**These are not portable and should not be listed as fixes.** The PerfectClip
time-of-impact tails in `CCylSphere::collide_with_point` and
`CSphere::collide_with_point` are x87 sequences that do not decompile legibly;
we took them from ACE. There is no retail text to port. Pretending otherwise
would put a "port it" ticket in the backlog forever.
The honest deliverable is **containment**: prove no production mover sets
PerfectClip, and add a guard or test that fails loudly if one ever does. Then
the rows describe a branch we can show is unreachable, which is the same
resolution AP-22 got.
---
### Parallel track — #330, headless live-entity collision
The headless host registers no live-entity collision at all: a bot walks
through every NPC and every server-spawned object. The graphical client is
unaffected.
**Genuinely parallel.** Different host, and — unlike everything above — it
needs **no live gate from the user at all**, because the headless suite can
assert it directly. It is the one item that can proceed while the user is
unavailable, which makes it the right thing to pick up whenever a live gate is
blocking.
---
## Gate economics
The user's time at the client is the only scarce resource here. Live gates are
therefore **batched, not per-slice**:
| Session | Covers | What to do in-world |
|---|---|---|
| A | PW-2 capture | drive the cliff edges that misbehave; capture only, no fix yet |
| B | S1 + S2 | a tight dungeon with a door; walk boundaries, jump, drop a corpse |
| C | S3 | doors: open, walk through, close, walk into |
| D | S4 + S5 | slopes: run down, run across, sled, land on inclines |
Four sessions for the whole campaign. Everything else is offline.
---
## Definition of done
- Each row either **retired** with its evidence, or **rewritten** with a
corrected population/direction, or **deleted** as describing something that
does not exist. All three are acceptable outcomes.
- No row is closed on a test that re-computes the production expression as its
own oracle. Use an independent DAT field or an independent implementation.
- Every discriminating test is **sabotage-verified**: break the production line
and watch the test redden, in the same session it is written.
- `docs/ISSUES.md` and the divergence register updated in the **same commit**
as the code, per the register's own two binding rules.

View file

@ -0,0 +1,774 @@
# #334 — implementation contract: port retail's BSP cell-membership path
**Filed** 2026-08-06. **Base** `f0588725`, branch
`claude/resume-session-e0bd03e1-d5bf45`.
**Status** IMPLEMENTED 2026-08-06. S0 measured (see the ISSUES #334 entry for the
figures); the S1/S2 split was collapsed into one commit because S1 alone changes no
behaviour and its P2 golden is asserted by S2 test T6. Deviations from this contract,
all reported at the fix: (a) §2.7 / T7 — the INDOOR part-array overload is NOT ported
and is now AP-159 / issue #335, so the fix covers the outdoor half only, which is the
whole of the measured defect; (b) the outdoor rectangle runs at seed time rather than
only from find_bbox_cell_lists residency-gated walk (AD-49); (c) §11.4 is RESOLVED —
CCellPortal::GetOtherCell @0x0053ba30 IS handed cellarray->do_not_load_cells as its
single explicit thiscall argument (0x0052cc27/0x0052cc2b); (d) §4.2s 7×7 upper bound
is EXCEEDED in the field — the worst single-owner rectangle over all installed
landblocks is 9×9, for the reason the contract itself caveated. Everything else in
§1 was independently byte-verified by disassembly and held.
**Original status** CONTRACT ONLY — no production code, no tests, no commit in the
session that produced this file.
**One line.** acdream has never implemented retail's
`CPhysicsObj::find_bbox_cell_list` path at all. Every object — including
BSP-bearing ones — is routed through a port of the *other* branch,
`CObjCell::find_cell_list`, whose outdoor expansion is a fixed 3×3 land-cell
neighbourhood. The fix is to implement the missing path, not to enlarge
anything.
---
## 0. Executive summary
Retail dispatches cell membership on `HAS_PHYSICS_BSP_PS` (0x10000) into two
structurally different algorithms. acdream implements one of them and uses it
for both.
| | retail | acdream at `f0588725` |
|---|---|---|
| BSP-bearing object | `find_bbox_cell_list` → per-part **bounding box** → filled land-cell **rectangle** | `BuildShadowCellSet` → per-part **sphere****3×3** neighbourhood |
| CylSphere object | `find_cell_list(cylspheres)` → 3×3 per sphere | same |
| Sorting-sphere object | `find_cell_list(sortingSphere)` → 3×3 | same shape, different source field (AP-157) |
The outdoor 3×3 is a **hard cap of ±1 cell (±24 m)** and is independent of the
sphere's radius — see §9.3 for the proof. This is why AP-156 (which fixed the
sphere's *position*) could not fix #334, and why widening the radius, adding a
second sphere, or tuning any constant cannot fix it either. Those are not
merely disallowed by policy; they are mechanically incapable of adding a tenth
cell.
---
## 1. Stage 1 — what retail actually does
All addresses below were **disassembled from the PDB-paired binary**
`C:\Users\erikn\Downloads\acclient.exe`
(`py tools/pdb-extract/check_exe_pdb.py``=== MATCH ===`, linker
2013-09-06T00:17:56Z, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`),
not taken from Binary Ninja. Every address in §1 was resolved to the construct
claimed for it. Four BN artifacts found in the process are listed in §9.
### 1.1 The dispatch (`CPhysicsObj::calc_cross_cells` @`0x00515230`, pc:283332)
```
00515285 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000
0051528f 7574 jne 0x515305 ; -> find_bbox_cell_list
00515291 8b4e10 mov ecx, [esi + 0x10] ; part_array
00515298 e8e32d0000 call 0x518080 ; GetNumCylsphere
0051529f 743b je 0x5152dc ; 0 -> sorting sphere
```
`0xa8` is `CPhysicsObj::state`; `0x10000` is `HAS_PHYSICS_BSP_PS`
(`acclient.h:2833`). The static twin
`CPhysicsObj::calc_cross_cells_static` @`0x00515160` (pc:283280) carries the
**identical** gate at `0x005151b0` and differs only in setting
`CELLARRAY::do_not_load_cells = 1`. Both tails are
`remove_shadows_from_cells``add_shadows_to_cells`.
### 1.2 The flood driver (`CPhysicsObj::find_bbox_cell_list` @`0x00510fc0`, pc:279006)
This function **forms no bounding box itself** — it is a worklist. That is the
grain of truth in the "no bounding box at all" claim, and it is why that claim
is misleading: the boxes are formed one and two levels down (§1.4, §1.6).
```
00510fd5 mov eax,[ebx+0x90] ; obj->cell
00510fe2 call 0x6b4ff0 ; CELLARRAY::add_cell(ca, cell->m_DID.id, cell) <- seed
00510ff8 mov eax,[esi+8] ; num_cells
00511012 call 0x518160 ; CPartArray::calc_cross_cells_static(pa, cell_i, ca)
00511017 mov eax,[esi+8] ; num_cells RE-READ each iteration <- the array GROWS
0051101d jb 0x511002
```
Seed with the object's own cell, then walk the array while it grows. Transitive
closure over the cell graph, terminated by `CELLARRAY::add_cell`'s dedup.
### 1.3 The per-cell dispatch (`CPartArray::calc_cross_cells_static` @`0x00518160`, pc:286228)
Three-instruction thunk. **Not** the extent walk, despite the name:
```
00518176 ff527c call dword ptr [edx + 0x7c] ; cell->vtable[0x7c]
```
`CObjCell`'s vftable base is `0x007c8b20`; `+0x7c` = `0x007c8b9c`, which holds
`0x0052b080` — the 4-argument
`find_transit_cells(uint numParts, CPhysicsPart** parts, CELLARRAY*)`
overload, distinct from the 6-argument `(Position, uint, CSphere*, CELLARRAY*,
SPHEREPATH*)` sphere/transition overload at `0x0052b070`.
Overrides: `CEnvCell` @`0x0052cae0` (pc:310127), `CLandCell` @`0x00533840`
(pc:317612), `CSortCell` @`0x00534080` (pc:318323), base `CObjCell`
@`0x0052b080` = `Turbine::Debug::Abort()`.
### 1.4 Outdoors — the extent walk (`CLandCell::add_all_outside_cells` @`0x00533360`, pc:317289)
`CLandCell::find_transit_cells` = `add_all_outside_cells` + `CSortCell`'s
building bridge. The extent walk is here.
```
if (cellarray.added_outside) return; # 0053336c, runs ONCE per flood
cellarray.added_outside = 1;
p0 = first non-null part; # 005333a2..005333ad
gid = adjust_to_outside(&p0->pos) ? outCellId : 0;
# 005333dd call 0x5a9bc0
# 005333eb neg esi / sbb esi,esi / and esi,eax
cell0 = LScape::get_landcell(landscape, gid); # 0053340c
if (!cell0) return; # 00533417 je 0x53361c
if (!gid_to_lcoord(gid, &gx, &gy)) return; # 00533428, GLOBAL land-cell coords
baseX = ((gid & 0xFFFF) - 1) >> 3; # 0053343a and eax,0xffff / dec / shr 3
baseY = (gid - 1) & 7; # 00533443 dec esi / and esi,7
minDX = minDY = maxDX = maxDY = 0; # 00533390..0053339c
for each non-null part p:
if (!p->Always2D()):
b = BBox::LocalToGlobal(p->gfxobj->gfx_bound_box, p->pos, cell0->pos);
# 0053350e GetBoundingBox, 00533527 LocalToGlobal
a = floor(b.min.x / 24); bb = floor(b.min.y / 24);
c = floor(b.max.x / 24); d = floor(b.max.y / 24);
else:
(sphere centre -/+ radius) / 24, floored
minDX = min(minDX, a - baseX); # 005335a2 sub esi,edx / jge
minDY = min(minDY, bb - baseY); # 005335b4
maxDX = max(maxDX, c - baseX); # 005335c2 / jle
maxDY = max(maxDY, d - baseY); # 005335d5
add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY, cellarray); # 00533614
```
**The four stack reads are byte-verified as `min.x, min.y, max.x, max.y`.** The
`fld` displacements (`[esp+0x48]`, `[esp+0x54]`, `[esp+0x5c]`, `[esp+0x60]`)
look inconsistent because `sub esp,8` at `0x533536` and `add esp,8` at
`0x533592` bracket the middle three; normalised to the entry frame they are
`+0x48, +0x4c, +0x54, +0x58`, and the out-`BBox` written by `LocalToGlobal`
(`lea ecx,[esp+0x54]` at three-pushes depth) is based at `+0x48`. A `BBox` is
`m_vMin`(0,4,8) `m_vMax`(0xc,0x10,0x14), so those four are exactly
min.x / min.y / max.x / max.y. Z is never read — land cells are a 2-D grid.
The four accumulators are initialised to **0**, so the rectangle always
contains the base cell even when the box math contributes nothing.
`square_length` = `0x7c920c` = `24.0f`, read from the binary
(`00 00 c0 41`).
### 1.5 Filling the rectangle (`CLandCell::add_cell_block` @`0x005331d0`, pc:317202)
```
for x = x0 .. x1 inclusive: # 005331e4 / 0053324d jle
for y = y0 .. y1 inclusive: # 005331f0 / 00533246 jle
if (x >= 0 && y >= 0 && x < 0x7f8 && y < 0x7f8): # 2040 = 255*8
id = (((x >> 3) << 8) | (y >> 3)) << 16 | ((x & 7) * 8 + (y & 7) + 1)
# 0053320a..0053322e
add_cell(ca, id, LScape::get_landcell(landscape, id))
```
Three properties that matter:
1. **The rectangle is filled, not outlined.** An L-shaped or diagonal object
claims cells its geometry never enters. Retail's coverage is deliberately
conservative.
2. **`x`/`y` are GLOBAL land-cell coordinates** over the 2040×2040 world grid
and the landblock prefix is re-derived per cell, so the rectangle **crosses
landblock boundaries freely**.
3. `add_cell` (`0x006b4ff0`) dedups by **id** via a linear scan and stores the
`LScape` pointer beside it — including `null` for a non-resident cell.
`gid_to_lcoord` @`0x00497a90` (pc:163500) byte-verified to return global
coords: `*x = ((gid>>21) & 0x7f8) + ((cellIdx-1)>>3)`,
`*y = (lby << 3) + ((cellIdx-1) & 7)`.
### 1.6 The box itself (`CPhysicsPart::GetBoundingBox` @`0x0050d600`, pc:274837)
```
0050d60a return &this->gfxobj->gfx_bound_box;
```
`gfx_bound_box` is filled by `CGfxObj::init_end` @`0x00534200` (pc:318480):
seed `min = max = vertices[0]`, then `BBox::AdjustBBox` over every vertex of
`vertex_array`. **It is the AABB of the GfxObj's vertex array in the GfxObj's
own frame** — the render vertex array, which is also the array the physics
polygons index into.
`BBox::LocalToGlobal` @`0x005b2120` (pc:448440) is a proper **8-corner
re-fit**: transform `min`, seed both corners from it, transform the other
seven and `AdjustBBox` each. A rotated box therefore grows, conservatively.
The output frame is `cell0->pos`'s — i.e. landblock-local metres, which is
what makes `floor(v / 24)` comparable to `baseX`/`baseY` in 0..7.
### 1.7 Indoors (`CEnvCell::find_transit_cells` @`0x0052cae0`, pc:310127)
Per portal × per part:
- centre of the part's physics sphere in cell-local space
(`Position::localtolocal`), tested against the portal plane with
`eps = 0.0002 + radius` (`0x0052cb65`) — a cheap reject;
- on pass, `BBox::LocalToLocal(partBBox, part->pos, cell->pos)` then
`Plane::intersect_box(portalPlane, box)` (`0x0052cc05`). **The admitting
test is box-vs-plane, not sphere-vs-plane.**
- if the result differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` sets
a flag meaning *this portal leads outside*; otherwise
`BBox::LocalToLocal` into the destination cell and
`CCellStruct::box_intersects_cell` gates the add.
- after all portals, the outside flag runs
`CLandCell::add_all_outside_cells` (`0x0052ccea`).
### 1.8 Answer to "is retail exact or conservative?"
**Conservative, in four compounding ways**, all in the over-inclusive
direction: the render-mesh AABB rather than the physics hull; axis-aligned
re-fit after rotation; a *filled* rectangle rather than a per-cell test; one
rectangle unioned across all parts rather than per-part rectangles. Retail
registers the object in cells its geometry does not touch and lets the narrow
phase reject. That is the safe direction (#98 / #168 are the other one), and it
means a faithful port does not need to be clever.
---
## 2. The exact change, by symbol
### 2.1 New: `CellTransit.BuildShadowCellSetFromParts` (`src/AcDream.Core/Physics/CellTransit.cs`)
Port of `find_bbox_cell_list` (§1.2). Signature mirrors
`BuildShadowCellSet`, taking part boxes instead of spheres:
```
public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
PhysicsDataCache cache,
uint seedCellId,
IReadOnlyList<ShadowPartBox> worldParts, // new value type, §2.3
bool isStatic)
```
Body: seed with `seedCellId`; walk `candidates` by index while it grows
(re-reading `Count`, §1.2); per candidate dispatch outdoor →
`AddAllOutsideCellsFromParts` + the existing building bridge, indoor →
`FindTransitCellsParts`.
### 2.2 New: `CellTransit.AddAllOutsideCellsFromParts`
Port of §1.4 + §1.5. Reuses the existing `AddOutsideCell` helper (already
global-lcoord and already landblock-crossing — do not touch it) inside a
double loop, with the `0 <= v < 0x7f8` clamp from §1.5. Guarded by the same
once-per-flood `added_outside` latch `BuildShadowCellSet` already models, but
note the **cardinality change**: the sphere overload runs the whole body per
sphere; the parts overload computes **one** rectangle over all parts and runs
once.
### 2.3 New: `ShadowPartBox` (`src/AcDream.Core/Physics/`)
`(Vector3 LocalMin, Vector3 LocalMax, Vector3 LocalPosition, Quaternion
LocalRotation, float Scale)` — the per-part input to the 8-corner re-fit.
Follow `ShadowShape`'s AP-156 precedent: **factory-only construction, with min
and max arriving as one value**, so no future call site can take one and drop
the other.
### 2.4 Changed: `ShadowShape` — carry the box
Add `LocalBoundsMin` / `LocalBoundsMax`, filled by the **same resolver that
already supplies `Radius` and `BoundsCenter`**. This is the AP-156 invariant
re-applied: one resolver, one value, scaled together.
Source: `FlatGfxObjVisualBounds.Min` / `.Max`, which
`FlatCollisionAssetBuilder.FlattenGfxObj` already computes from
`PhysicsDataCache.ComputeVisualBounds(source.VertexArray)` — **the exact
`CGfxObj::init_end` computation** — and which
`FlatCollisionAssetSerializer` already writes into the prepared package. **No
bake-format change, no DAT re-read, no new parsing.** This is the single
largest de-risking fact in this contract.
Resolvers to widen: `ShadowShapeBuilder.FromSetup`'s
`physicsBspBounds: Func<uint, FlatCollisionSphere?>` and
`FromLandblockBspParts`'s `Func<uint, GfxObjPhysics?> getGfxObj`;
`LiveEntityCollisionBuilder._physicsBspBounds` is the single live supplier.
### 2.5 Changed: `ShadowObjectRegistry.RegisterMultiPart`
The dispatch, mirroring §1.1 — this is the whole fix in one place:
```
bool hasBsp = shapes.Any(s => s.CollisionType == ShadowCollisionType.BSP);
var cellSet = hasBsp
? CellTransit.BuildShadowCellSetFromParts(FloodCache, seed, boxes, isStatic)
: CellTransit.BuildShadowCellSet (FloodCache, seed, spheres, spheres.Count, isStatic);
```
### 2.6 What happens to `BuildFloodSpheres`
**It stays, unchanged, and keeps its cap logic** — it is a correct port of the
`!HAS_PHYSICS_BSP` branch's two arms, which retail still uses for CylSphere and
sorting-sphere objects. What changes is that its **BSP arm becomes dead**: with
the §2.5 dispatch, a shape list containing a BSP shape never reaches it.
Delete the BSP arm rather than leaving it unreachable. That arm's XML doc
(`ShadowObjectRegistry.cs:436-442`, "A BSP part contributes its ROOT BOUNDING
SPHERE placed at its real center") becomes false the moment §2.5 lands and must
go with it. Leaving a dead-but-plausible BSP arm behind is exactly how a future
producer silently re-acquires the bug.
Objects that legitimately are spherical are **untouched**: same function, same
cap, same 3×3, byte-identical cell sets. That is proof obligation P2.
### 2.7 Indoor half: `CellTransit.FindTransitCellsParts`
Port of §1.7 alongside `FindTransitCellsSphere` (which stays for the sphere
route). This is the half AP-156's row already names as its open residual.
---
## 3. Interaction with what landed tonight
### 3.1 AP-156 (`b52967de`) — **this port RETIRES its open residual**
AP-156's row states its remainder explicitly: *"Closing it means porting the
per-cell `find_transit_cells` part-array overload, which is different work from
getting the sphere set right."* That is precisely §2.1/§2.2/§2.7. **Sequential,
not competing; AP-156 is a prerequisite and stands.**
Two register consequences, both in the same commit as the fix:
- **AP-156's Risk column is FALSE as written and must be corrected before it is
retired.** It records the traversal residual as *"extra broadphase
candidates, never a missed one."* #334 is a missed one. The row generalised
the **indoor** direction (sphere-vs-portal-plane, over-inclusive) to the
whole residual, and the **outdoor** direction is the opposite: a fixed 3×3
that is under-inclusive for every object wider than one cell. Correct the row
first, then retire it — a row deleted while still carrying a false risk
statement takes the finding with it.
- `BoundsCenter` stays. It still positions the sphere for the non-BSP routes
and for the `eps = 0.0002 + radius` portal pre-reject in §1.7.
### 3.2 AP-152 (`4abd1b5e`) — **preserved and depended on, not conflicting**
AP-152 made shape emission BSP-exclusive: a BSP-bearing Setup emits BSP shapes
and no primitive. §2.5's `hasBsp` predicate is therefore *unambiguous* — post
AP-152 a shape list is homogeneous in practice, so "has a BSP shape" and
"is a BSP object" coincide, exactly as retail's cached `HAS_PHYSICS_BSP_PS`
does. **Without AP-152 this dispatch would be ill-defined.** Nothing to narrow
or retire; add a cross-reference from AP-152's row.
### 3.3 AP-158 / #333 — **a blocking interaction, and the one thing that can make this fix look like it did nothing**
The broadphase reach filter discards a candidate when
`distToCurr > sphereRadius + obj.Radius + movement + 2f`, measuring from the
**part origin**. This port's whole purpose is to register objects in cells
*further from the part origin than the sphere reaches* — which is the precise
input that makes AP-158 fire.
Bound: a player standing at the far corner of the new rectangle is up to
`~1.73·R + |BoundsCenter|` from the part origin, against a budget of
`R + r + move + 2`. For `R = 69.471` and the measured
`|BoundsCenter| = 34.977` that is ~155 m tested against ~72 m — **rejected**.
For the specific Neftet object the fix does still work: the player positions in
the two adjacent cells sit ~50 m from the part origin against a ~72 m budget,
so those cells pass. **But the general statement is that #334's fix is
necessary and not sufficient**, and the AP-156 fix review already recorded this
exact failure mode one layer up ("the fix may produce no visible change at all,
because the geometry now lands in the right cell and is then discarded by the
filter"). Do not let it happen twice.
**Directive:** the §7 gate must report `rejectedReach` per scenario. A gate that
shows `inCell` rise while `rejectedReach` rises with it is a **fail**, and the
remedy is #333, not a wider budget here.
---
## 4. Cost — measured where possible, and honestly bounded where not
### 4.1 What the cost is, structurally
Cells per object changes from **≤ 9, position-dependent** to
**(⌈Xextent/24⌉+1) × (⌈Yextent/24⌉+1)**.
The crossover is exact and favourable: **any object whose XY extent is ≤ 24 m
yields at most 2×2 = 4 cells — fewer than today's 9.** The port is *cheaper*
for every creature, prop, door and item, and more expensive only for objects
wider than one land cell. Those are landblock-baked terrain formations and
building shells.
### 4.2 Measured worst live case
From the committed probe log (`334-neftet-probe.log`), the only object in the
sample above 1.4 m: `gfx=0x010046D8`, `objR = 69.471`,
`|bspCentreOffset| = 34.977`. The three other BSP objects observed are
1.075 / 1.271 / 1.370 m — i.e. 1×1 rectangles, strictly cheaper than today.
Upper bound for the outlier: the box is contained in the mesh's extent, so
extent ≤ 2R = 138.9 m → at most `floor(138.9/24)+1 = 6` cells per axis, +1 for
straddle = **7×7 = 49 cells**, versus 9 today. Caveat stated plainly: this
bound assumes the BSP root sphere bounds the whole vertex array; it bounds the
*physics polygons'* vertices, which are a subset, so a render-only vertex
outside it would exceed the bound.
### 4.3 Where the cost lands relative to existing budgets
- **Not on the per-frame resolve path.** Slice I1 measured 0 B/resolve for
player, remote, projectile, camera and grounded walkable-publication
profiles; the flood is registration-time, not resolve-time. The ordinary
production profile's CPU/GPU p50 of 1.869 / 1.096 ms is not exposed to it.
- **Landblock statics** (`isStatic: true`, both hosts): once per landblock
publication, already metered by the Slice E retirement/publication budgets.
- **Live remotes:** `RuntimeRemotePhysicsUpdater` re-floods per tick, gated on
>1 cm movement / rotation / cell change. Creature extents are ≪ 24 m → ≤ 4
cells → **strictly cheaper than the current 3×3 on the hottest path in the
system.**
### 4.4 The real cost is memory, not CPU
`_cells` is `Dictionary<uint, List<ShadowEntry>>` and
`RegisterMultiPart` writes **every shape row into every flooded cell**. Rows
per object = `shapes × cells`. For a many-part baked formation at 49 cells this
is a 5.4× row multiplication over today's 9. Landblock-baked part arrays are
the population with both the largest part counts and the largest extents, so
the two multiply.
### 4.5 What I could NOT measure, and the measurement to run first
**I did not enumerate the installed distribution of physics-BSP GfxObj bounding
boxes.** It is not derivable from anything in the repo: the register's existing
figures (973 physics-BSP parts, 530 BSP-bearing Setups, 477 unique physics-BSP
GfxObjs, 118 above 2.5 m offset, 46 above 5 m) are all **sphere** statistics.
**Required before any code is written** — same route the AP-156 and #333
figures used (an out-of-repo scratch program over the installed
`client_portal.dat`), reporting over all 477 unique physics-BSP GfxObjs:
1. histogram of `ceil(Xextent/24)+1` × `ceil(Yextent/24)+1`;
2. the count exceeding 1×1, 2×2 and 4×4;
3. the worst case, with its gfx id;
4. total `Σ shapes × cells` over one dense landblock (Arwic) before and after.
**Gate:** if the p99 rectangle exceeds 7×7 or the dense-Arwic row total more
than doubles, stop and report rather than proceeding. That is the point at
which "the faithful port is too expensive" becomes a real finding and the
honest alternative — retail's own `CELLARRAY` growth policy, or a shared row
rather than a per-cell copy — gets designed deliberately instead of discovered
in a profile.
---
## 5. Blast radius — BOTH hosts, checked not inferred
`ShadowObjectRegistry` and `CellTransit` are in **`AcDream.Core`**, which both
hosts reference. Project graph read from the `.csproj` files:
```
AcDream.Headless -> AcDream.Runtime -> {Core, Core.Net, Content, Plugin.Abstractions}
AcDream.App -> {Runtime, Core, Core.Net, Content, UI.Abstractions, Plugins.Smoke}
```
### 5.1 Production call sites of `RegisterMultiPart` (complete)
| site | host reach |
|---|---|
| `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:178` | App only |
| `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:959, 1044` | App only |
| `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:619, 700` | **App AND Headless** |
### 5.2 Headless is reached — verified by call site, not by dependency inference
`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` calls
`LandblockPhysicsContentBuilder.HydrateStaticEntities` (:386),
`HydrateProceduralScenery` (:392), `BuildDatBundle` (:403),
`PublishPreparedCells` (:435), `CacheBuildings` (:442),
`CachePreparedObjects` (:447). Lines 619 and 700 of that builder — the
`FromLandblockBspParts` BSP path and the `FromSetup` path — are exactly the
sites that register the landblock-baked formations #334 is about.
**Headless registers the same objects through the same Core code and is
affected identically.** This is the survey C5b missed and the direction AP-152's
contract got wrong; it is settled here by reading the call sites.
### 5.3 Consumers that must NOT change
`_cells` shape is unchanged (same key, same row type), so every reader —
`TransitionTypes`, `PhysicsEngine`, `CollisionWorldState`,
`RuntimePhysicsState`, `ShadowPositionSynchronizer`,
`RuntimeCollisionReportingState`, `ProjectileController`, camera collision —
sees only a different *membership*, never a different *shape*. No consumer
signature changes.
---
## 6. Proof obligations and test plan
### 6.1 Proof obligations
- **P1 — rectangle equality.** For a BSP object the registered outdoor set
equals `add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY)` exactly:
not a superset, not a subset, and *filled*.
- **P2 — non-BSP invariance.** Cylinder-only and Sphere-only owners register
byte-identical cell sets to `f0588725`.
- **P3 — map bounds.** No registered cell has a global lcoord outside
`[0, 0x7f8)`.
- **P4 — host agreement.** App and Headless produce the same cell set for the
same landblock and seed.
- **P5 — negative-safe floor.** `floor`, not truncation (see trap §8.5).
### 6.2 Tests — each with the sabotage that must redden it
Every fixture below is **non-degenerate on the axis under test**: in particular
**every BSP fixture has an extent that exceeds its own radius**, which is the
whole property at issue. A fixture whose box fits inside its sphere makes the
new path and the old path agree and proves nothing — that is the failure mode
this campaign has now hit ten times.
| # | test | sabotage that MUST redden it |
|---|---|---|
| T1 | Part with 100 m × 100 m box and 1 m sphere → rectangle spans ≥ 5 cells per axis | swap the box for the sphere → collapses to 3×3 |
| T2 | Two parts forming an L → the notch cell **is** present (rectangle is filled) | compute per-part rectangles and union them → notch disappears |
| T3 | Box reaching past cellX 7 → cells carry the **neighbour landblock's** prefix | clamp the rectangle to the seed landblock |
| T4 | Rectangle at the map corner → no cell outside `[0, 0x7f8)` | drop the `0x7f8` clamp |
| T5 | Non-cubic box rotated 37° → rectangle grows vs unrotated | transform only min/max instead of all 8 corners |
| T6 | Cylinder-only owner → cell set identical to a `f0588725` golden | route every owner through the box path |
| T7 | Owner straddling an EnvCell portal whose **box** crosses the plane but whose **sphere** does not → destination cell present | keep `FindTransitCellsSphere` on the BSP route |
| T8 | Seed cell not resident → outdoor registration skipped, no throw (§1.4 `if (!cell0) return`) | drop the null check |
| T9 | Negative delta (box extends below the base cell) → cells with lower lcoord present | use `(int)(v/24f)` truncation instead of `MathF.Floor` |
### 6.3 T10 — the installed-DAT replay of the measured evidence (strongest test)
Assert that `gfx=0x010046D8` (entity `0xC8764000`, position
`(63.78, 248.29, 0.08)`, from the committed probe log) registers into
**`0x87640011` and `0x87640019`** — the two cells the probe measured EMPTY —
as well as `0x8764000A` and `0x87640012`, which it measured populated.
Sabotage: revert `RegisterMultiPart` to `BuildFloodSpheres` → the two new cells
vanish.
This asserts observed reality and re-encodes no constant under test.
**Precondition that must be honoured, not assumed.** Whether the box actually
reaches those two cells is a **prediction, not a measurement**. §9.3 establishes
that the current 3×3 is centred on cell `0x87640013` (x=2, y=2) and therefore
structurally cannot reach cellY 0 — that half is proven. Whether
`0x010046D8`'s box extends ≥ 48 m in Y is not.
**Directive:** measure `0x010046D8`'s `FlatGfxObjVisualBounds` first and derive
the expected rectangle from it. If the measured box does **not** reach
`0x87640011` / `0x87640019`, **stop and report** — that would mean the
diagnosis is incomplete and a second mechanism is present. Do not weaken the
test to match; do not pin the cell list before the box is read.
### 6.4 Not permitted
No source-text pins. No test asserting `24f` or `0x7f8` by reading the
constant it is testing. No test whose expected cell set was produced by running
the new code.
---
## 7. Gate design — positive evidence, named observables
`ACDREAM_PROBE_REACH` (`b61f5fd4`,
`PhysicsDiagnostics.ProbeReachEnabled`) produces the before/after comparison
directly. Capture one log at `f0588725` and one at the fix commit, same route.
**Per-scenario pass condition** — all three must hold:
1. a `[reach-obj]` row with `gfx=0x010046D8` appears in cells where it did not
before;
2. that row's `disp` is `tested-*`, **not** `rejected-reach` (§3.3);
3. `[reach-q]`'s `inCell` rises **and** `rejectedReach` does **not** rise with
it.
**Scenarios, named by the user's own report:**
- **G1 — walk through on flat ground.** Approach a formation on level ground
and walk into its face. Observable: blocked, `blocked ≥ 1`.
- **G2 — the boundary between two formations.** Walk along the seam where two
formations meet — the exact geometry the report calls out. Observable:
continuously blocked across the seam; before the fix `inCell=2 exempt=2` with
no rock row, after it a rock row in every cell along the seam.
- **G3 — jump over and land inside.** Jump onto/over a formation. Observable:
lands **on** the geometry, does not fall through; a walkable contact plane is
reported at the landing tick.
- **G4 — a formation the fix should not change.** Any 1×1-extent prop nearby:
its cell set must be unchanged (visual P2 corroboration).
**Regression gates:** Release build (after deleting all `bin`/`obj` — four
stale-DLL incidents this session, one under `-t:Rebuild`); complete solution
suite; the exact-binary lifecycle/reconnect route; the canonical nine-stop
route; the native-Linux Headless multi-session run, since §5.2 puts Headless in
scope.
---
## 8. Traps
1. **AP-158 masks the fix in far cells.** §3.3. The most likely way this lands
green and changes nothing the user can see.
2. **Cardinality change.** The sphere overload runs its whole body *per
sphere*; the parts overload computes **one** rectangle over all parts and
runs **once**. Reusing the sphere loop's per-item structure produces N
rectangles and silently breaks T2.
3. **`0x00518160` is not the extent walk.** It is a 3-instruction vtable thunk.
The extent walk is `0x00533360`. #334's issue body cites the former as "a
walk over the object's extent" (§9.2).
4. **Two functions named `calc_cross_cells_static`.**
`CPhysicsObj::` @`0x00515160` is a *caller* of `find_bbox_cell_list`;
`CPartArray::` @`0x00518160` is the thunk *below* it. They are not on the
same level and confusing them inverts the call graph.
5. **`floor`, not truncation.** Retail calls `floor` then `_ftol2`. C#
`(int)(v / 24f)` truncates toward zero and is wrong for every negative
block-local coordinate. T9.
6. **The base gid comes from the FIRST NON-NULL PART's
`adjust_to_outside`,** not from the object's position (`0x005333a2`).
`DeriveOutdoorSeed` clamps to the seed block; the rectangle must not
inherit that clamp.
7. **Global vs within-landblock indices in the same expression.**
`gid_to_lcoord` returns global coords; `baseX`/`baseY` are within-block
0..7. The deltas bridge them. Mixing the two frames is the single most
likely arithmetic error, and BN's own output already drops the
`and eax,0xffff` that makes `baseX` within-block (§9.4).
8. **Do not touch `AddOutsideCell`.** It is already correct and already
landblock-crossing; the new path composes it.
9. **The `isStatic` prune is indoor-seeded only.** The outdoor rectangle is
deliberately unpruned. Extending the prune to it would re-create #334 in a
new form.
10. **Don't leave `BuildFloodSpheres`' BSP arm unreachable-but-plausible.**
§2.6.
---
## 9. Claims found FALSE or STALE at `f0588725`
### 9.1 "`find_bbox_cell_list` forms no bounding box at all" — MISLEADING; refuted as a characterisation
Literally true of that one function (§1.2 — it is a worklist driver). False as a
description of the mechanism: the boxes are formed in
`CLandCell::add_all_outside_cells` (§1.4, `GetBoundingBox` +
`BBox::LocalToGlobal`) and `CEnvCell::find_transit_cells` (§1.7,
`BBox::LocalToLocal` + `Plane::intersect_box`), one and two levels below. The
name is accurate. Reading only the top frame and stopping is what produced the
claim.
### 9.2 `docs/ISSUES.md` #334 — address chain imprecise
The issue reads *"`find_bbox_cell_list` @0x00510fc0`calc_cross_cells_static`
@0x00518160, i.e. a walk over the object's extent."* The **routing is correct**
and the **conclusion is correct**, but `0x00518160` is `CPartArray::`'s vtable
thunk (§1.3), not an extent walk, and the similarly named
`CPhysicsObj::calc_cross_cells_static` @`0x00515160` is a *caller* of
`find_bbox_cell_list`, not a callee. Correct chain:
`0x00515230``0x00510fc0``0x00518160``[vtbl+0x7c]`
`0x00533840``0x00533360``0x005331d0`.
### 9.3 #334's stated cause is right but understates the mechanism — and this is what kills "widen the sphere"
The issue attributes the loss to the sphere's radius (69.471 m) being smaller
than a landblock (192 m). The operative cap is not the radius at all.
`CellTransit.AddAllOutsideCells` computes `minRad = radius`,
`maxRad = 24 radius`, and adds at most the **eight** neighbours of the
sphere's own cell. **For any radius ≥ 12 m both boundary tests are
unconditionally true and the result is exactly the 3×3 — a larger radius cannot
add a tenth cell.** Outdoor reach is hard-capped at ±24 m for every object in
the game.
Consequence: widening the radius, adding a supplementary sphere at another
point (it would produce its own 3×3, not a joined region), or tuning any
constant is *mechanically* incapable of fixing this, not merely disallowed.
**Independent confirmation against the measured data.** Global lcoords
(lbx=0x87, lby=0x64): present `(1081,801)`, `(1082,801)`; absent
`(1082,800)`, `(1083,800)`, `(1082,799)`. A 3×3 centred at `(1082,802)` — cell
`0x87640013`, x=2, y=2 — contains both present cells and excludes all three
absent ones. Every other candidate centre contradicts at least one observation.
The player's own logged positions (`currPos ≈ (52.4, 216.0)` while in
`0x87640012`) independently constrain the landblock origin to the same
solution. **The 3×3 hypothesis explains the measured evidence with zero
contradictions.**
### 9.4 Binary Ninja artifacts in `acclient_2013_pseudo_c.txt` — four, all in the load-bearing function
Anyone porting §1.4 from the pseudo-C alone gets these wrong:
1. `add_all_outside_cells` pc:317343 renders `baseX` as
`((uint32_t)esi_4 - 1) >> 3`. **BN dropped the `and eax, 0xffff`**
(`0x0053343a`). Without it `baseX` includes the landblock bits and every
delta is garbage.
2. pc:317330 renders the base-gid select as `((esi_2 - esi_2) & var_58)`,
which is identically **zero**. The real code is the standard
`neg esi / sbb esi,esi / and esi,eax` conditional select
(`0x005333eb`) = `retval ? outsideCellId : 0`.
3. `add_cell_block` pc:317219 renders
`LScape::get_landcell(landscape, edx_2)` with `edx_2 = i & 7`. The real
argument is `esi`, the full computed cell id (`0x00533230 push esi`).
Same artifact in `add_all_outside_cells` (`..., added_outside)` where
`added_outside == 0`).
4. Both `x87` flag tests in the min/max accumulation appear as
`unimplemented {test ah, ...}` / bit-shuffled `FCMP_UO` expressions. The
real comparisons are plain integer `jge`/`jle` on `_ftol2` results
(`0x005335a6`, `0x005335b8`, `0x005335c8`, `0x005335d9`) — the fifth
confirmed instance this campaign of BN dropping flag semantics.
### 9.5 AP-156's Risk column is FALSE for the outdoor half
Recorded as *"extra broadphase candidates, never a missed one."* #334 is a
missed one. §3.1.
### 9.6 `ShadowObjectRegistry.cs:436-442` XML doc becomes false on landing
*"A BSP part contributes its ROOT BOUNDING SPHERE placed at its real center."*
True at `f0588725`; false the moment §2.5 lands. Delete with the BSP arm
(§2.6).
### 9.7 Stale, not false
`CellTransit.BuildShadowCellSet`'s XML calls itself *"the sphere-overlap portal
flood retail runs at SHADOW REGISTRATION time"* — accurate for the branch it
ports, but it is presented as **the** registration flood when it is one of two.
Narrow the wording when §2.1 lands.
---
## 10. Size estimate and split call
~600800 production lines (two `CellTransit` ports, one value type, the
`ShadowShape` field and its resolvers, the `RegisterMultiPart` dispatch, the
`BuildFloodSpheres` BSP-arm deletion) plus ~400 test lines.
**Split: THREE commits, sequential, one agent, no parallelism** (shared files —
`CellTransit.cs`, `ShadowShape.cs`, `ShadowObjectRegistry.cs` — are touched by
every slice).
| slice | content | gate |
|---|---|---|
| **S0** | §4.5 measurement only. No repo change. | numbers reported; the §4.5 stop-gate evaluated |
| **S1** | `ShadowPartBox`, `ShadowShape` bounds + resolvers, package/serializer read-through. No behaviour change. | full suite green; P2 golden cell sets bit-identical |
| **S2** | `AddAllOutsideCellsFromParts` + `BuildShadowCellSetFromParts` + `FindTransitCellsParts` + the §2.5 dispatch + BSP-arm deletion | T1T10; P1P5; Release; both hosts; §7 connected gate |
S0 is not optional. It is the slice that can still say "this is too expensive"
before anything is written, which is the only honest way to make that call.
**Rollback:** each slice reverts independently; S2 alone restores `f0588725`
behaviour.
---
## 11. What I could not establish
1. **The installed distribution of physics-BSP GfxObj bounding boxes** — §4.5.
Not derivable from the repo; every existing figure is a sphere statistic.
S0 exists to close this.
2. **Whether `0x010046D8`'s box actually reaches `0x87640011` / `0x87640019`**
— §6.3. The *absence* is proven and its cause is proven; the *presence
after the fix* is a prediction until the box is read. The contract makes
reading it a precondition rather than an assumption, because a contract
asserting a mechanism that does not exist is how this campaign produced
three defects.
3. **Whether `0x010046D8` is one object or several instances sharing a gfx
id.** The log shows one entity id (`0xC8764000`) across all 1,356 rows, so
one instance is the working assumption; a second instance elsewhere in the
landblock would not change the diagnosis but would change T10's expected
set.
4. **Whether `CCellPortal::GetOtherCell` takes `do_not_load_cells` as a third
argument.** BN reads the field at `0x0052cc2a` but shows a two-argument
call. Only matters for indoor static registration (§2.7); resolve by
disassembly during S2 rather than porting BN's shape.

View file

@ -0,0 +1,279 @@
# #337 — the Neftet plateau wedge: mechanism, measured
**Date:** 2026-08-06
**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
(`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.**
---
## The fix, as landed
**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.
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.
### 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.
---
## Separate observation, not part of this defect — now filed as #338
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.

View file

@ -5,8 +5,23 @@
applies and is repeated in §6).
Branch `claude/acdream-physics-divergence-5aa784`, 21 commits from `02578441`
to `7b3e2895`. **Nothing is pushed** — the branch does not exist on the remote,
and there are 388+ unpushed commits ahead of `origin/main`.
to `7b3e2895`.
**CORRECTED 2026-08-06 — the original line here said "Nothing is pushed — the
branch does not exist on the remote, and there are 388+ unpushed commits ahead
of `origin/main`". That was FALSE, and false in the direction that would most
alarm a successor.** The campaign was merged to `main` and pushed the same day:
`main` and `github/main` are both `d4e956b4`, and `git branch --contains
7b3e2895` lists `main`.
**The measurement error, because it will recur.** This repo has TWO remotes.
`github` (`git@github.com:eriknihlen/acdream.git`) is the live one. `origin`
(`https://git.snakedesert.se/erik/acdream.git`) is a second remote whose cached
ref `origin/main` still points at `f6275f45` and whose ref file has not been
written since **April 27** — three months stale. `main` is 412 commits ahead of
that ref, which is where "388+ unpushed" came from. **Never measure push state
against `origin/*` in this repo without checking the ref's age first**; compare
against `github/main`, or check `git branch --contains <sha>`.
---

View file

@ -47,7 +47,7 @@ internal sealed record LiveEntityCollisionRegistration(
/// </remarks>
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, FlatCollisionSphere?> _physicsBspBounds;
private readonly Func<uint, ShadowPartGeometry?> _physicsBspBounds;
/// <summary>
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
@ -62,11 +62,13 @@ internal sealed class LiveEntityCollisionBuilder
: this(
id =>
{
FlatPhysicsBsp? flat =
physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id);
FlatPhysicsBsp? flat = asset?.PhysicsBsp;
return flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere
: (FlatCollisionSphere?)null;
? ShadowPartGeometry.Create(
flat.Nodes[flat.RootIndex].BoundingSphere,
asset!.VisualBounds)
: (ShadowPartGeometry?)null;
},
defaultPose)
{
@ -74,14 +76,15 @@ internal sealed class LiveEntityCollisionBuilder
}
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP root
/// bounding sphere, or null when it has none. ONE resolver answers both
/// questions the builder asks — "does this part dispatch as BSP?" and
/// "where and how big is its flood sphere?" — so the dispatch gate and
/// the emitted geometry cannot disagree, and the sphere's radius cannot
/// be carried while its origin is dropped. That split is what produced
/// the AP-156 mis-placed flood.</param>
/// bounding sphere AND its authored vertex-array box, or null when it has
/// no physics BSP. ONE resolver answers every question the builder asks —
/// "does this part dispatch as BSP?", "where and how big is its flood
/// sphere?", and "what is its outdoor extent?" — so the dispatch gate and
/// the emitted geometry cannot disagree, the sphere's radius cannot be
/// carried while its origin is dropped (AP-156), and the outdoor extent
/// walk cannot be left without a box (#334).</param>
internal LiveEntityCollisionBuilder(
Func<uint, FlatCollisionSphere?> physicsBspBounds,
Func<uint, ShadowPartGeometry?> physicsBspBounds,
LiveEntityDefaultPoseResolver defaultPose)
{
_physicsBspBounds = physicsBspBounds

View file

@ -0,0 +1,372 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
/// <summary>
/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337
/// probe family).
///
/// <para>
/// The F2 collision overlay predating this class drew, for a BSP object, a
/// proxy cylinder sized from the object's registered BROADPHASE radius. That
/// answers "where does the collision system think this object roughly is" and
/// nothing more. The open question in Neftet is a different one — whether an
/// object's collision SURFACES are where its visual mesh is drawn — and a
/// proxy sphere cannot answer it in either direction.
/// </para>
///
/// <para>
/// This draws the actual geometry instead, in three colours that are meant to
/// be read against each other:
/// <list type="bullet">
/// <item><b>Cyan</b> — the object's real physics-BSP polygon edges, in world
/// space. These are the surfaces a body can stand on or be stopped by.
/// Where the cyan mesh sits away from the rock you can see, the collision
/// is displaced; where a visible rock has no cyan on it at all, it has no
/// collision geometry.</item>
/// <item><b>Magenta</b> — the same object's VISUAL mesh bounding box, from
/// the same prepared assets the renderer draws from. It is the reference
/// the cyan is judged against, so the comparison does not depend on the
/// eye's guess about where the visual "really" is.</item>
/// <item><b>Yellow</b> — the outdoor terrain surface under the player, as a
/// grid of the physics engine's own sampled heights. If a body is resting
/// on the yellow rather than on cyan, terrain is holding it up and the
/// object's collision is not involved at all.</item>
/// </list>
/// Dim orange keeps the old broadphase proxy visible so nothing the previous
/// overlay showed has been taken away.
/// </para>
///
/// <para>
/// Geometry is resolved through the SAME prepared collision accessors the
/// resolver queries (<c>PhysicsDataCache.GetFlatGfxObj</c> /
/// <c>GetVisualBounds</c>) and placed with the SAME world transform the
/// collision probes use, so this cannot draw a shape the collision system does
/// not actually hold. Reading the geometry by a second route is how AP-156
/// managed to report a sphere the registry never emitted.
/// </para>
///
/// <para>
/// Pure reads. Nothing here mutates physics, registry, or render state; the
/// caller owns the <see cref="DebugLineRenderer"/> frame.
/// </para>
/// </summary>
internal sealed class CollisionMeshWireframe
{
// Colours, in the order the class comment lists them.
private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f);
private static readonly Vector3 VisualColor = new(1f, 0f, 1f);
private static readonly Vector3 TerrainColor = new(1f, 1f, 0f);
private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f);
/// <summary>
/// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's
/// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock
/// 0x8766 carries the largest single collision owner measured in the game
/// (an 81-cell footprint), and an uncapped walk of it would be the one
/// place this overlay falls over.
/// </summary>
private const int MaxLines = 40_000;
/// <summary>Per-object polygon ceiling, so one enormous formation cannot
/// consume the whole budget and hide every other object near it.</summary>
private const int MaxPolygonsPerObject = 4_000;
/// <summary>Half-width in metres of the terrain grid drawn under the
/// player, and its sample spacing.</summary>
private const float TerrainGridHalfWidth = 12f;
private const float TerrainGridStep = 2f;
private readonly PhysicsEngine _physics;
public CollisionMeshWireframe(PhysicsEngine physics)
=> _physics = physics ?? throw new ArgumentNullException(nameof(physics));
/// <summary>
/// Emit the overlay for everything within
/// <see cref="RenderingDiagnostics.CollisionMeshWireframeRadius"/> of
/// <paramref name="centre"/>. Returns what it drew so the caller can
/// report a capped frame rather than silently showing partial geometry.
/// </summary>
public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre)
{
ArgumentNullException.ThrowIfNull(lines);
float radius = RenderingDiagnostics.CollisionMeshWireframeRadius;
float radiusSquared = radius * radius;
var budget = new LineBudget(MaxLines);
int objects = 0;
int polygons = 0;
int withoutGeometry = 0;
PhysicsDataCache? cache = _physics.DataCache;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
// Objects register their part ORIGIN, which for a BSP part is
// routinely nowhere near the geometry itself (376 of the 973
// installed physics-BSP parts sit further from their own bounding
// centre than half their radius). Admitting on origin distance
// ALONE would drop exactly the large displaced-centre formations
// this overlay exists to look at, so the object's own radius is
// added to the window.
float reach = radius + shadow.Radius;
if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach)
continue;
objects++;
if (shadow.CollisionType != ShadowCollisionType.BSP)
{
DrawBroadphaseProxy(lines, in shadow, budget);
continue;
}
FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId);
int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget);
polygons += drawn;
if (drawn == 0) withoutGeometry++;
DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget);
DrawBroadphaseProxy(lines, in shadow, budget);
}
DrawTerrainGrid(lines, centre, budget);
return new CollisionMeshWireframeStats(
ObjectsConsidered: objects,
PolygonsDrawn: polygons,
ObjectsWithoutPhysicsGeometry: withoutGeometry,
LinesDrawn: budget.Used,
Capped: budget.Capped);
}
/// <summary>
/// Walk the object's physics BSP and emit one closed edge loop per polygon
/// the tree actually indexes. Polygons the tree does not reference are NOT
/// drawn: no query can reach them, so showing them would overstate the
/// collision surface. Returns the polygon count emitted.
/// </summary>
private static int DrawPhysicsPolygons(
DebugLineRenderer lines,
in ShadowEntry shadow,
FlatGfxObjCollisionAsset? asset,
Vector3 centre,
float radiusSquared,
LineBudget budget)
{
FlatPhysicsBsp? bsp = asset?.PhysicsBsp;
if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0)
return 0;
FlatPolygonTable table = bsp.PolygonTable;
ImmutableArray<Vector3> vertices = table.Vertices;
int emitted = 0;
foreach (FlatPhysicsBspNode node in bsp.Nodes)
{
FlatIndexRange indices = node.PolygonIndexRange;
for (int i = indices.Start; i < indices.EndExclusive; i++)
{
if (emitted >= MaxPolygonsPerObject || budget.Exhausted)
return emitted;
int polygonIndex = bsp.PolygonIndexStream[i];
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
FlatIndexRange span = table.Polygons[polygonIndex].VertexRange;
if (span.Count < 2) continue;
Vector3 first = ToWorld(vertices[span.Start], in shadow);
// Per-polygon distance rejection, AFTER the world transform:
// a big object admitted by the object-level window still only
// needs the faces near the player drawn.
if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue;
Vector3 previous = first;
for (int v = span.Start + 1; v < span.EndExclusive; v++)
{
Vector3 current = ToWorld(vertices[v], in shadow);
if (!budget.TryAdd()) return emitted;
lines.AddLine(previous, current, PhysicsColor);
previous = current;
}
if (span.Count > 2)
{
if (!budget.TryAdd()) return emitted;
lines.AddLine(previous, first, PhysicsColor);
}
emitted++;
}
}
return emitted;
}
/// <summary>
/// The visual mesh box, placed with the SAME transform as the physics
/// polygons above. It is drawn as the object's own rotated box (eight
/// transformed corners, twelve edges) rather than as a world-axis-aligned
/// box, so a rotated object's magenta lines still bound its actual visual.
/// </summary>
private static void DrawVisualBounds(
DebugLineRenderer lines,
in ShadowEntry shadow,
GfxObjVisualBounds? visual,
LineBudget budget)
{
if (visual is null || budget.Exhausted) return;
Vector3 min = visual.Min;
Vector3 max = visual.Max;
Span<Vector3> corners =
[
ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow),
ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow),
ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow),
ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow),
ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow),
ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow),
ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow),
ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow),
];
ReadOnlySpan<int> edges =
[
0, 1, 1, 2, 2, 3, 3, 0,
4, 5, 5, 6, 6, 7, 7, 4,
0, 4, 1, 5, 2, 6, 3, 7,
];
for (int e = 0; e < edges.Length; e += 2)
{
if (!budget.TryAdd()) return;
lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor);
}
}
/// <summary>
/// The registered broadphase shape — what the pre-#337 overlay showed, and
/// what the collision system's reach filter measures against. Kept so this
/// overlay is a superset of the one it replaces.
/// </summary>
private static void DrawBroadphaseProxy(
DebugLineRenderer lines,
in ShadowEntry shadow,
LineBudget budget)
{
// AddCylinder emits a fixed 36 lines. Reserve them together so a
// partial ring cannot be drawn.
if (!budget.TryAdd(36)) return;
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
{
float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f;
lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor);
return;
}
lines.AddCylinder(
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
shadow.Radius,
shadow.Radius * 2f,
BroadphaseColor);
}
/// <summary>
/// The terrain surface under the player, sampled through the physics
/// engine's own height resolver — the same numbers the resolver grounds
/// against, not a re-derivation. Drawn as a grid rather than as the single
/// containing triangle so the slope around the player reads at a glance.
/// </summary>
private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget)
{
int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep);
float originX = centre.X - TerrainGridHalfWidth;
float originY = centre.Y - TerrainGridHalfWidth;
for (int ix = 0; ix <= steps; ix++)
{
for (int iy = 0; iy <= steps; iy++)
{
float x = originX + ix * TerrainGridStep;
float y = originY + iy * TerrainGridStep;
float? z = _physics.SampleTerrainZ(x, y);
if (z is null) continue;
var here = new Vector3(x, y, z.Value);
if (ix < steps)
{
float nx = x + TerrainGridStep;
if (_physics.SampleTerrainZ(nx, y) is { } nz)
{
if (!budget.TryAdd()) return;
lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor);
}
}
if (iy < steps)
{
float ny = y + TerrainGridStep;
if (_physics.SampleTerrainZ(x, ny) is { } nz2)
{
if (!budget.TryAdd()) return;
lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor);
}
}
}
}
}
/// <summary>
/// The one placement formula, matching the <c>[resolve-bldg]</c> probe's
/// world transform for a shadow part
/// (<c>TransitionTypes.FindObjCollisionsInCell</c>): scale in the part's
/// own frame, then rotate, then translate to the registered position.
/// </summary>
private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow)
=> shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation);
/// <summary>
/// Mutable line counter shared across the draw. A class rather than a
/// struct so the per-shape helpers can be static and still share it
/// without ref-plumbing through every signature.
/// </summary>
private sealed class LineBudget(int limit)
{
public int Used { get; private set; }
public bool Capped { get; private set; }
public bool Exhausted => Used >= limit;
public bool TryAdd(int count = 1)
{
if (Used + count > limit)
{
Capped = true;
return false;
}
Used += count;
return true;
}
}
}
/// <summary>What one <see cref="CollisionMeshWireframe.Draw"/> emitted.
/// <paramref name="ObjectsWithoutPhysicsGeometry"/> is the interesting one: a
/// non-zero count means objects near the player carry no reachable collision
/// polygons at all.</summary>
internal readonly record struct CollisionMeshWireframeStats(
int ObjectsConsidered,
int PolygonsDrawn,
int ObjectsWithoutPhysicsGeometry,
int LinesDrawn,
bool Capped);

View file

@ -64,6 +64,10 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
private readonly DebugVmRenderFactsPublisher _debugVm;
private readonly bool _debugVmConsumerActive;
private int _debugDrawLogCount;
// #337 (TEMPORARY): built on first use so the ordinary overlay path and
// every headless/no-window host pay nothing for it.
private CollisionMeshWireframe? _meshWireframe;
private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false);
public WorldSceneDiagnosticsController(
WorldRenderDiagnostics diagnostics,
@ -215,6 +219,18 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
if (!_state.CollisionWireframesVisible || _lines is null)
return;
// #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the
// broadphase-proxy overlay below with the objects' real physics-BSP
// polygon edges beside their visual mesh boxes and the terrain surface
// — see CollisionMeshWireframe for why the proxy cannot answer the
// question this mode exists for. Default off; F2 keeps its old
// behaviour otherwise.
if (RenderingDiagnostics.CollisionMeshWireframeEnabled)
{
DrawCollisionMesh(in camera);
return;
}
_lines.Begin();
int drawn = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
@ -255,6 +271,50 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
_lines.Flush(camera.Camera.View, camera.Projection);
}
/// <summary>
/// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on
/// the player when there is one, else on the camera, so the fly-camera
/// mode can inspect geometry too.
/// </summary>
private void DrawCollisionMesh(in WorldCameraFrame camera)
{
Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller
? controller.Position
: camera.Position;
_meshWireframe ??= new CollisionMeshWireframe(_physics);
_lines!.Begin();
CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre);
if (_mode.IsPlayerMode && _player.Controller is { } player)
{
_lines.AddCylinder(
player.Position,
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
1.8f,
new Vector3(1f, 0f, 0f));
}
_lines.Flush(camera.Camera.View, camera.Projection);
// A capped frame is showing PARTIAL geometry, which would otherwise be
// indistinguishable from an object that has none — exactly the
// misreading this overlay exists to prevent. Say so, throttled, rather
// than letting the picture lie.
if (stats != _lastMeshStats)
{
_lastMeshStats = stats;
Console.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " +
"noPhysicsGeometry={5} lines={6} capped={7}",
centre.X, centre.Y, centre.Z,
stats.ObjectsConsidered, stats.PolygonsDrawn,
stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped));
}
}
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
{
if (_debugDrawLogCount >= 5)

View file

@ -372,6 +372,146 @@ public static class CellTransit
}
}
/// <summary>
/// Outdoor extent walk for a physics-BSP part array — the OTHER outdoor
/// expansion retail has, and the one #334 was missing entirely. Verbatim
/// port of <c>CLandCell::add_all_outside_cells</c> @0x00533360 (pc:317289)
/// plus <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202),
/// disassembled from the PDB-paired 2013-09-06 binary rather than read
/// from Binary Ninja's pseudo-C, which mis-renders four separate
/// constructs inside this one function
/// (<c>docs/research/2026-08-06-334-contract.md</c> §9.4).
///
/// <para>
/// Shape, byte-verified:
/// </para>
/// <list type="number">
/// <item>The base landcell comes from the FIRST part's own
/// <c>adjust_to_outside</c> (<c>0x005333a2</c>-<c>0x005333dd</c>),
/// NOT from the object's position; a failed adjust selects gid 0
/// through the <c>neg/sbb/and</c> conditional select at
/// <c>0x005333eb</c>, whose <c>get_landcell</c> then returns null
/// and the walk returns (<c>0x00533417</c>).</item>
/// <item><c>baseX = ((gid &amp; 0xFFFF) - 1) >> 3</c>
/// (<c>0x0053343a and eax,0xffff</c> — the mask BN drops) and
/// <c>baseY = (gid - 1) &amp; 7</c> (<c>0x00533443</c>): WITHIN-BLOCK
/// 0..7, bridged to the GLOBAL lcoords from
/// <c>gid_to_lcoord</c> (<c>0x00533428</c>) by the four deltas.</item>
/// <item>Per part: <c>GetBoundingBox</c> @0x0050d600 →
/// <c>BBox::LocalToGlobal</c> @0x005b2120 (<c>0x00533527</c>), then
/// <c>floor(v / square_length)</c> on min.x, min.y, max.x, max.y
/// (stack slots <c>+0x48/+0x4c/+0x54/+0x58</c> in the entry frame —
/// the raw displacements differ only because <c>sub esp,8</c> at
/// <c>0x00533536</c> brackets the middle three). Z is never read:
/// land cells are a 2-D grid. <c>square_length</c> is
/// <c>0x7c920c</c> = <c>00 00 c0 41</c> = 24.0f, read from the
/// binary.</item>
/// <item>The four accumulators are seeded to ZERO
/// (<c>0x00533390</c>-<c>0x0053339c</c>), so the rectangle always
/// contains the base cell, and are combined with plain integer
/// <c>jge</c>/<c>jle</c> (<c>0x005335a6</c>, <c>0x005335b8</c>,
/// <c>0x005335c8</c>, <c>0x005335d9</c>) — BN renders these as
/// <c>unimplemented {test ah}</c> / <c>FCMP_UO</c>.</item>
/// <item>ONE rectangle over ALL parts, filled — not outlined, not a
/// per-part union (<c>0x00533614</c>
/// <c>add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY,
/// cellarray)</c>, argument order recovered from the five pushes
/// at <c>0x005335f2</c>-<c>0x00533613</c>). An L-shaped object
/// claims the notch; retail's coverage is deliberately
/// conservative and lets the narrow phase reject.</item>
/// </list>
///
/// <para>
/// Retail also has an <c>Always2D()</c> arm (<c>0x0053346b</c>) that falls
/// back to the part's sphere. It is unreachable here: this overload is
/// only ever handed physics-BSP parts, and a 2-D sprite part carries no
/// physics BSP.
/// </para>
/// </summary>
/// <param name="worldParts">The object's physics-BSP parts, world-placed.</param>
/// <param name="currentCellId">The flood seed cell — supplies the landblock
/// base <c>adjust_to_outside</c> measures part 0's position against.</param>
/// <param name="currentBlockOrigin">World origin of the seed cell's
/// landblock (#106 frame convention); <see cref="Vector3.Zero"/> when the
/// seed block IS the anchor.</param>
/// <returns>False when <c>adjust_to_outside</c> or <c>gid_to_lcoord</c>
/// rejects the base position (map edge / invalid id) — retail returns
/// without adding anything.</returns>
public static bool AddAllOutsideCellsFromParts(
IReadOnlyList<ShadowPartBox> worldParts,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
if (worldParts is null || worldParts.Count == 0)
return false;
// 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell.
// DeriveOutdoorSeed clamps its own result to the seed block; this
// deliberately does not inherit that clamp — a part array whose first
// part sits over the neighbour block anchors there, as retail does.
Vector3 seedFramePos = worldParts[0].WorldPosition - currentBlockOrigin;
Vector3 baseFramePos = seedFramePos;
uint baseCellId = currentCellId;
if (!LandDefs.AdjustToOutside(ref baseCellId, ref baseFramePos))
return false; // gid 0 → get_landcell null → return
if (!LandDefs.GidToLcoord(baseCellId, out int gx, out int gy))
return false; // 0x00533432 je 0x53361c
int baseX = (int)(((baseCellId & 0xFFFFu) - 1u) >> 3); // 0x0053343a
int baseY = (int)((baseCellId - 1u) & 7u); // 0x00533443
// adjust_to_outside re-based part 0's position into the ADJUSTED
// block's local frame; retail's BBox::LocalToGlobal writes every part
// box into that same frame (cell0->pos). The re-basing is a pure
// translation, so applying it to the world origin is exact.
Vector3 frameOrigin =
currentBlockOrigin - (baseFramePos - seedFramePos);
int minDX = 0, minDY = 0, maxDX = 0, maxDY = 0; // 0x00533390
for (int i = 0; i < worldParts.Count; i++)
{
worldParts[i].RefitTo(frameOrigin, out Vector3 boxMin, out Vector3 boxMax);
// floor, then _ftol2 — NOT truncation. C#'s (int)(v / 24f)
// truncates toward zero and is wrong for every negative
// block-local coordinate, which is precisely the case a part
// hanging off the block's SW corner produces.
int a = (int)MathF.Floor(boxMin.X / LandDefs.CellLength);
int b = (int)MathF.Floor(boxMin.Y / LandDefs.CellLength);
int c = (int)MathF.Floor(boxMax.X / LandDefs.CellLength);
int d = (int)MathF.Floor(boxMax.Y / LandDefs.CellLength);
if (a - baseX < minDX) minDX = a - baseX; // 0x005335a2
if (b - baseY < minDY) minDY = b - baseY; // 0x005335b4
if (c - baseX > maxDX) maxDX = c - baseX; // 0x005335c2
if (d - baseY > maxDY) maxDY = d - baseY; // 0x005335d5
}
AddCellBlock(gx + minDX, gy + minDY, gx + maxDX, gy + maxDY, candidates);
return true;
}
/// <summary>
/// <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202): both loops are
/// INCLUSIVE (<c>0x0053324d</c> / <c>0x00533246 jle</c>) and the rectangle
/// is FILLED. Coordinates are GLOBAL lcoords, so the landblock prefix is
/// re-derived per cell and the rectangle crosses landblock boundaries
/// freely — that re-derivation is <see cref="AddOutsideCell"/>'s
/// <see cref="LandDefs.LcoordToGid"/>, whose
/// <see cref="LandDefs.InBounds"/> rejection IS retail's
/// <c>0 &lt;= v &lt; 0x7f8</c> clamp at <c>0x005331f0</c>-<c>0x00533206</c>.
/// </summary>
private static void AddCellBlock(
int x0, int y0, int x1, int y1,
ICollection<uint> candidates)
{
for (int x = x0; x <= x1; x++)
for (int y = y0; y <= y1; y++)
AddOutsideCell(candidates, x, y);
}
private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
{
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
@ -500,8 +640,13 @@ public static class CellTransit
}
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an
/// object with NO physics BSP — the sphere-overlap portal flood retail
/// runs at SHADOW REGISTRATION time for the cylsphere and sorting-sphere
/// branches. It is ONE of TWO registration floods: a BSP-bearing object
/// takes <see cref="BuildShadowCellSetFromParts"/> instead
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230 dispatches on
/// <c>HAS_PHYSICS_BSP_PS</c> at <c>0x00515285</c>).
/// Verbatim port of <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0,
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
@ -657,6 +802,184 @@ public static class CellTransit
return candidates.OrderedIds;
}
/// <summary>
/// #334 (2026-08-06). Registration-side cell-set builder for a
/// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which
/// acdream had never implemented. Port of
/// <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 (pc:279006), the
/// branch <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 takes at
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c> — <c>HAS_PHYSICS_BSP_PS</c>
/// (<c>acclient.h:2833</c>). <see cref="BuildShadowCellSet"/> ports the
/// branches BELOW that jump (cylspheres, then the sorting sphere) and
/// remains correct for them.
///
/// <para>
/// <c>find_bbox_cell_list</c> forms no bounding box itself — it is a
/// worklist. It seeds the array with the object's OWN cell
/// (<c>0x00510fe2 CELLARRAY::add_cell</c>) and walks it while it grows,
/// re-reading <c>num_cells</c> each iteration
/// (<c>0x00510ff8</c> / <c>0x00511017</c> / <c>0x0051101d jb</c>),
/// dispatching each array cell through
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 — a forwarding
/// thunk to <c>cell-&gt;vtable[0x7c]</c> (<c>0x00518176</c>;
/// <c>CObjCell</c>'s vftable base <c>0x007c8b20</c> + <c>0x7c</c> =
/// <c>0x007c8b9c</c>, holding <c>0x0052b080</c>, the four-argument
/// part-array <c>find_transit_cells</c>). The boxes are formed one and two
/// levels down: outdoors in
/// <see cref="AddAllOutsideCellsFromParts"/>
/// (<c>CLandCell::find_transit_cells</c> @0x00533840 =
/// <c>add_all_outside_cells</c> @0x00533360 + the <c>CSortCell</c>
/// @0x00534080 building bridge), indoors in
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0.
/// </para>
///
/// <para>
/// Note the difference from <see cref="BuildShadowCellSet"/>'s seed: the
/// sphere overload calls <c>add_all_outside_cells</c> AT SEED TIME for an
/// outdoor id (<c>CObjCell::find_cell_list</c> <c>0x0052b53f</c>);
/// <c>find_bbox_cell_list</c> does not — the outdoor expansion happens
/// only when the walk reaches a landcell, under the same once-per-flood
/// <c>CELLARRAY::added_outside</c> latch (<c>0x0053336c</c>). And it is
/// ONE rectangle over all parts, run ONCE, not a per-part loop.
/// </para>
///
/// <para>
/// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array
/// overload — box-vs-portal-plane
/// (<c>BBox::LocalToLocal</c> @0x005b1e60 + <c>Plane::intersect_box</c>
/// @0x005aa170 at <c>0x0052cbf9</c>/<c>0x0052cc05</c>) and
/// <c>CCellStruct::box_intersects_cell</c> @0x00533910 — is NOT ported
/// here. Indoor candidates keep the sphere-vs-portal traversal
/// <see cref="FindTransitCellsSphere"/> already runs, from the same
/// per-part BSP root spheres, which is byte-for-byte the behaviour every
/// BSP object had before #334. AP-156's row already names that port as its
/// open residual; #334 is the OUTDOOR half of it.
/// </para>
/// </summary>
/// <param name="worldParts">Per-part world-placed authored boxes — the
/// outdoor extent walk's input.</param>
/// <param name="worldPartSpheres">Per-part world-placed BSP root spheres —
/// the indoor residual's and the building bridge's input. Same parts, same
/// order, from the same <see cref="ShadowPartGeometry"/> values.</param>
public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
PhysicsDataCache cache,
uint seedCellId,
IReadOnlyList<ShadowPartBox> worldParts,
IReadOnlyList<Sphere> worldPartSpheres,
bool isStatic)
{
var candidates = new CellArray();
if (seedCellId == 0u || worldParts is null || worldParts.Count == 0)
return candidates.OrderedIds;
int sphereCount =
EffectiveSphereCount(worldPartSpheres, worldPartSpheres?.Count ?? 0);
uint seedLow = seedCellId & 0xFFFFu;
cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin);
// SEED. 0x00510fd5-0x00510fe2 adds the object's own cell by id;
// 0x00510fed / 0x00510ff6 then skip the walk when the cell or the part
// array is null.
//
// DEVIATION (registered, AD-40): the outdoor rectangle runs at seed
// time here, not only from the walk. Retail can gate everything on
// obj->cell because a placed CPhysicsObj always has a resident
// CObjCell; acdream's CellGraph residency is transiently false during
// streaming (#168 / #169), and deferring the rectangle to the walk
// would drop a static or a live entity to a single cell for the window
// before its landblock publishes. This is the SAME residency policy
// BuildShadowCellSet already applies to its outdoor seed
// (CObjCell::find_cell_list 0x0052b53f, ahead of the arg4 walk gate),
// so the two registration floods differ only in sphere-vs-box — which
// is the whole of #334 — and the direction is over-inclusive.
bool outdoorAdded = false; // CELLARRAY::added_outside
bool seedLoaded;
if (seedLow >= 0x0100u)
{
candidates.Add(seedCellId);
seedLoaded = cache.GetCellStruct(seedCellId) is not null;
}
else
{
candidates.Add(seedCellId);
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
}
if (!seedLoaded)
return candidates.OrderedIds;
for (int i = 0; i < candidates.Count; i++)
{
uint cellId = candidates.OrderedIds[i];
if ((cellId & 0xFFFFu) >= 0x0100u)
{
var cell = cache.GetCellStruct(cellId);
if (cell is null) continue; // 0x00511009 null cell pointer
if (sphereCount == 0) continue;
FindTransitCellsSphere(
cache, cell, cellId, worldPartSpheres!, sphereCount,
candidates, out bool exitStraddle);
if (exitStraddle && !outdoorAdded)
{
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
}
}
else
{
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// CLandCell::find_transit_cells @0x00533840:
// add_all_outside_cells (added_outside-guarded) then the
// CSortCell building bridge for this landcell's building.
if (!outdoorAdded)
{
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
}
var building = cache.GetBuilding(cellId);
if (building is not null && sphereCount > 0)
{
CheckBuildingTransit(
cache, building, worldPartSpheres!, sphereCount,
candidates, out _);
}
}
}
// Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY.
// The outdoor rectangle is deliberately unpruned: pruning it would
// re-create #334 in a new form.
if (isStatic && seedLow >= 0x0100u)
{
var seedCell = cache.GetCellStruct(seedCellId);
if (seedCell is not null)
{
var keep = new List<uint>(candidates.Count);
foreach (uint id in candidates.OrderedIds)
{
if (id == seedCellId || seedCell.VisibleCellIds.Contains(id))
keep.Add(id);
}
if (keep.Count != candidates.Count)
{
candidates.Clear();
foreach (uint id in keep) candidates.Add(id);
}
}
}
return candidates.OrderedIds;
}
/// <summary>
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP

View file

@ -197,6 +197,10 @@ public sealed class PhysicsDataCache
{
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
}
GfxObjVisualBounds? parsedBounds =
_visualBounds.TryGetValue(gfxObjId, out var cachedBounds)
? cachedBounds
: null;
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
{
@ -217,6 +221,10 @@ public sealed class PhysicsDataCache
Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
FlatPhysicsBsp = prepared?.PhysicsBsp,
VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb
? new FlatGfxObjVisualBounds(
pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents)
: null),
};
_gfxObj[gfxObjId] = physics;
@ -285,6 +293,7 @@ public sealed class PhysicsDataCache
Radius = root.Radius,
},
FlatPhysicsBsp = prepared.PhysicsBsp,
VisualBounds = prepared.VisualBounds,
});
}
@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics
/// omit it.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
/// <summary>
/// Retail <c>CGfxObj::gfx_bound_box</c> — the AABB of this GfxObj's vertex
/// array, filled by <c>CGfxObj::init_end</c> @0x00534200 and returned by
/// <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600. Cached beside
/// <see cref="BoundingSphere"/> so the single
/// <c>ShadowShapeBuilder.FromLandblockBspParts</c> resolver answers both of
/// retail's cell-membership questions from one lookup (#334).
/// </summary>
public FlatGfxObjVisualBounds? VisualBounds { get; init; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>

View file

@ -1146,6 +1146,788 @@ public static class PhysicsDiagnostics
public static bool ProbeSweptEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1";
// -----------------------------------------------------------------------
// #334 broadphase / candidate-disposition probe — TEMPORARY (2026-08-06)
//
// STRIP THIS WHOLE REGION together with the rest of the physics probe
// family once #334 is scored.
//
// #334 is "large static formations can be walked through on flat ground,
// and jumping over them drops you inside". Three candidate mechanisms
// survive the report, and this probe exists to DISCRIMINATE them, not to
// 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).
// 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);
// (c) the object IS a candidate and IS NOT rejected, but resolves to no
// usable shape — an empty shape list or an unresolved physics BSP.
//
// Context for (a): the filter measures |currPos - obj.Position| — the part
// ORIGIN — against obj.Radius (the physics-BSP ROOT sphere radius) plus a
// 2 m acdream-invented slack. The BSP root sphere's CENTRE is frequently
// NOT the part origin (376 of 973 installed physics-BSP parts sit further
// from it than half their own radius; worst 20.762 m). Where that offset
// exceeds the slack, geometry well inside the sphere is rejected. So every
// candidate line carries BOTH distances and the decisive
// wouldAcceptAtCenter boolean.
//
// Context for (c): AP-152 (4abd1b5e) made us emit BSP shapes exclusively
// where primitives were also emitted before. That did NOT cause #334 (the
// user reproduced on a pre-AP-152 build), but the same failure mode can
// exist independently, so no-shape is a first-class disposition here.
// -----------------------------------------------------------------------
/// <summary>
/// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with
/// the physics-probe family). Emits two line types from
/// <c>Transition.FindObjCollisionsInCell</c>:
///
/// <list type="bullet">
/// <item><c>[reach-q]</c> — one per-cell query summary: the number of
/// shadow entries the cell yielded and the per-disposition tallies.
/// It is emitted <b>even when the cell yields zero entries</b>, which is
/// what makes outcome (b) visible: "cell yielded 0" at a spot where a
/// formation is plainly in front of the player is a registration gap,
/// and is recorded as data rather than as silence. Without this line an
/// absence of rejection lines would be ambiguous between "nothing was
/// rejected" and "nothing was there", which is precisely the
/// unfalsifiable-criterion trap this campaign has already been bitten
/// by.</item>
/// <item><c>[reach-obj]</c> — one per candidate, carrying its identity
/// (mover guid, target entity id, GfxObj id, cell) and its
/// <c>disposition</c>: <c>exempt-self</c>, <c>exempt-missile</c>,
/// <c>exempt-rule</c>
/// (<c>rejected-reach</c> is retired — #333 deleted the filter that
/// produced it),
/// <c>exempt-ethereal-stepdown</c>, <c>no-shape</c>,
/// <c>bsp-only-skip</c>, or <c>tested:&lt;result&gt;</c>. For BSP
/// candidates it also carries the origin-measured distance the filter
/// used, the centre-measured distance it should have used, the budget,
/// the shortfall, and <c>wouldAcceptAtCenter</c>.</item>
/// </list>
///
/// <para>
/// Volume control (this site is hot — it runs per cell per transitional
/// insert, and one resolve performs many inserts). <c>[reach-obj]</c> is
/// de-duplicated per (mover, target, cell) and re-emits immediately
/// whenever the disposition changes or the shortfall crosses a 0.5 m
/// bucket, and otherwise at most once per second. <c>[reach-q]</c> is
/// de-duplicated per (mover, cell) on the full tally tuple, so any change
/// in what the cell yielded emits at once, and otherwise at most twice a
/// second. Both therefore emit eagerly on change — which is exactly when
/// the player walks into the formation — and stay quiet when nothing is
/// happening. Nothing is aggregated away: every distinct state the query
/// passes through appears.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_REACH=1</c>. Zero cost when off
/// (one static bool read per query).
/// </para>
/// </summary>
public static bool ProbeReachEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REACH") == "1";
private static readonly object _reachGate = new();
private static readonly Dictionary<(uint Mover, uint Entity, uint Cell), (long Ms, string Disp, int Bucket)>
_reachSeenObj = new();
private static readonly Dictionary<(uint Mover, uint Cell), (long Ms, long Tally)>
_reachSeenQuery = new();
/// <summary>
/// One <c>[reach-obj]</c> line. Self-guards on
/// <see cref="ProbeReachEnabled"/>.
/// </summary>
/// <param name="moverId">The moving entity's guid — never omitted; a
/// per-entity probe without an identity produced a wrong root cause once
/// already (<c>feedback_probe_identity_attribution</c>).</param>
/// <param name="disposition">What happened to this candidate. Use the
/// documented vocabulary on <see cref="ProbeReachEnabled"/>.</param>
/// <param name="distOrigin">What the reach filter measured: the distance
/// from the swept sphere's current centre to the target's part ORIGIN.
/// Negative when not applicable.</param>
/// <param name="distCenter">What it should have measured: the distance to
/// the target's physics-BSP root sphere CENTRE. Negative when not
/// applicable.</param>
/// <param name="budget">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.</param>
/// <param name="centerBudget">The same threshold WITHOUT the slack — the
/// honest conservative bound once the real centre is used.</param>
public static void LogReachCandidate(
uint moverId,
uint entityId,
uint gfxObjId,
uint cellId,
ShadowCollisionType shape,
string disposition,
bool stepDown,
float distOrigin,
float distCenter,
float objRadius,
float sphereRadius,
float movementLen,
float budget,
float centerBudget,
Vector3 objPos,
Vector3 bspCentreOffset,
Vector3 currPos)
{
if (!ProbeReachEnabled) return;
float shortfall = distOrigin - budget;
bool wouldAcceptAtCenter = distCenter >= 0f && distCenter <= centerBudget;
int bucket = distOrigin < 0f ? 0 : (int)MathF.Floor(shortfall / 0.5f);
long now = Environment.TickCount64;
lock (_reachGate)
{
var key = (moverId, entityId, cellId);
if (_reachSeenObj.TryGetValue(key, out var prev)
&& string.Equals(prev.Disp, disposition, StringComparison.Ordinal)
&& prev.Bucket == bucket
&& now - prev.Ms < 1000)
{
return;
}
_reachSeenObj[key] = (now, disposition, bucket);
}
Console.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"[reach-obj] mover=0x{0:X8} obj=0x{1:X8} gfx=0x{2:X8} cell=0x{3:X8} " +
"disp={4} shape={5} stepDown={6} distOrigin={7:F3} distCenter={8:F3} " +
"objR={9:F3} sphereR={10:F3} move={11:F3} budget={12:F3} " +
"centerBudget={13:F3} shortfall={14:F3} wouldAcceptAtCenter={15} " +
"objPos=({16:F2},{17:F2},{18:F2}) " +
"bspCentreOffset=({19:F2},{20:F2},{21:F2}) |bspCentreOffset|={22:F3} " +
"currPos=({23:F2},{24:F2},{25:F2}) t={26}",
moverId, entityId, gfxObjId, cellId,
disposition, shape, stepDown, distOrigin, distCenter,
objRadius, sphereRadius, movementLen, budget,
centerBudget, shortfall, wouldAcceptAtCenter,
objPos.X, objPos.Y, objPos.Z,
bspCentreOffset.X, bspCentreOffset.Y, bspCentreOffset.Z, bspCentreOffset.Length(),
currPos.X, currPos.Y, currPos.Z, now));
}
/// <summary>
/// One <c>[reach-q]</c> per-cell query summary. MUST be called even when
/// the cell yields zero entries — that is the whole point of the line.
/// Self-guards on <see cref="ProbeReachEnabled"/>.
/// </summary>
/// <param name="inCell">Shadow entries the cell yielded, before any
/// exemption. Zero here at a spot with visible geometry is outcome (b).</param>
/// <param name="reached">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.</param>
/// <param name="rejectedReach">Of those, how many the reach filter
/// rejected — outcome (a). <b>Structurally 0 since #333 deleted the
/// filter</b>, and retained precisely so that a post-fix capture reading 0
/// is comparable against the pre-fix capture that read 7,225.</param>
/// <param name="noShape">Candidates that passed the filter but resolved to
/// no usable shape — outcome (c).</param>
/// <param name="tested">Candidates that actually reached a shape test.</param>
public static void LogReachQuery(
uint moverId,
uint cellId,
bool stepDown,
int inCell,
int exempt,
int reached,
int rejectedReach,
int noShape,
int tested,
int blocked,
Vector3 currPos)
{
if (!ProbeReachEnabled) return;
// Tally fingerprint: any change in what this cell yielded re-emits at
// once. Deliberately includes every counter, so a state the query
// passes through cannot be swallowed by the throttle.
long tally = (((long)inCell * 31 + exempt) * 31 + reached) * 31;
tally = ((tally + rejectedReach) * 31 + noShape) * 31;
tally = ((tally + tested) * 31 + blocked) * 31 + (stepDown ? 1 : 0);
long now = Environment.TickCount64;
lock (_reachGate)
{
var key = (moverId, cellId);
if (_reachSeenQuery.TryGetValue(key, out var prev)
&& prev.Tally == tally
&& now - prev.Ms < 500)
{
return;
}
_reachSeenQuery[key] = (now, tally);
}
Console.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"[reach-q] mover=0x{0:X8} cell=0x{1:X8} stepDown={2} inCell={3} " +
"exempt={4} reached={5} rejectedReach={6} noShape={7} tested={8} " +
"blocked={9} pos=({10:F2},{11:F2},{12:F2}) t={13}",
moverId, cellId, stepDown, inCell,
exempt, reached, rejectedReach, noShape, tested,
blocked, currPos.X, currPos.Y, currPos.Z, now));
}
// -----------------------------------------------------------------------
// [support] / [geom] — #337 "what is holding this body up, and is the
// collision geometry where the visual geometry is?" (2026-08-06 —
// TEMPORARY, strip with the physics-probe family).
//
// WHY A NEW FAMILY RATHER THAN MORE [resolve].
// ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out
// position + cell, ok, groundedIn, a THREE-VALUE contact-plane token
// (valid / lastKnown / none), the collision normal + responsible entity if
// something was hit, and one walkable-polygon bool. That is enough to say
// THAT the body stopped. It cannot say WHAT held it up, because it prints
// no plane normal, no plane height, no terrain sample, and no attribution
// for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain
// holding the body, (b) an object surface holding it somewhere other than
// where the rock is drawn, and (c) an unobstructed transition that simply
// fails to advance all produce the SAME [resolve] line. Two diagnoses this
// campaign have already been refuted by measurement; a probe that cannot
// separate the remaining three is not worth the launch.
//
// WHAT SEPARATES THEM.
// [support] — per resolve, per body (players AND corpses/NPCs, which is
// what makes the fall-through case observable at all). It samples the
// OUTDOOR TERRAIN independently at the body's own out-XY and prints
// the contact plane's own height at that same XY. Two independent
// heights at one point:
// cpZ@out == terrZ → terrain is the support, whatever set it.
// cpZ@out >> terrZ → an object surface is the support.
// cpValid=false → nothing is; the body is in free fall.
// `cpSrc` names the code site that wrote the plane, so the classifier
// and the provenance are cross-checkable rather than one inferring
// the other.
// [geom] — once per GfxObj that comes near the mover. Compares the
// object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in
// the same local frame. If the collision geometry is absent, empty,
// displaced, or the wrong size, this line says so directly. That is
// the working hypothesis's refutation test: `verdict=coincident`
// kills "the collision isn't where the visual is" outright, and no
// amount of movement-side evidence is then needed to rule it out.
//
// Neither line gates, orders, or mutates anything. Both are pure reads.
// -----------------------------------------------------------------------
/// <summary>
/// Initial state from <c>ACDREAM_PROBE_SUPPORT=1</c>. Enables the
/// <c>[support]</c> and <c>[geom]</c> lines described above. Zero cost when
/// off (one static bool read per resolve and per collision candidate).
/// TEMPORARY — strip with the rest of the physics-probe family.
/// </summary>
public static bool ProbeSupportEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1";
/// <summary>Vertical agreement window, in metres, inside which the contact
/// plane's height and the terrain's height at the same XY are called the
/// same surface.</summary>
public const float SupportSameSurfaceZ = 0.05f;
/// <summary>Straight-up-component agreement window inside which the contact
/// plane's tilt and the terrain triangle's tilt are called the same
/// surface. 0.02 is roughly 1 degree near flat.</summary>
public const float SupportSameSurfaceNormalZ = 0.02f;
private static readonly object _supportGate = new();
private static readonly Dictionary<uint, (long Ms, long Signature)> _supportSeen = new();
private static readonly HashSet<uint> _geomSeen = new();
// Contact-plane provenance latch. Ten distinct sites call
// CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat),
// cell BSP, three water paths, and a straight-up fallback — and the plane
// they write is indistinguishable once stored, so [support]'s
// classification would have no independent cross-check.
//
// This deliberately does NOT live on CollisionInfo. That object's stored
// members are compared member-for-member by the flat/graph differential
// referee and by the transition-scratch reset poison test; adding a
// diagnostic field there makes both oracles report a difference that is
// not a difference, and the only way to keep them green is to teach them
// to skip a member — which is how a referee quietly stops refereeing.
// [ThreadStatic] because a headless host ticks several sessions in
// parallel and physics is synchronous within each.
[ThreadStatic] private static string? _contactPlaneSourceMember;
[ThreadStatic] private static int _contactPlaneSourceLine;
/// <summary>
/// Clear the provenance latch. Call once per resolve, before the sweep, so
/// a resolve that establishes no plane reports <c>none</c> rather than the
/// previous resolve's answer. No-op unless
/// <see cref="ProbeSupportEnabled"/>.
/// </summary>
public static void BeginContactPlaneAttribution()
{
if (!ProbeSupportEnabled) return;
_contactPlaneSourceMember = null;
_contactPlaneSourceLine = 0;
}
/// <summary>
/// Record the site asserting a contact plane. Called by
/// <c>CollisionInfo.SetContactPlane</c> with compiler-supplied literals;
/// self-guarded, so it is a single flag test when the probe is off and
/// allocates nothing when it is on.
/// </summary>
public static void RecordContactPlaneSource(string member, int line)
{
if (!ProbeSupportEnabled) return;
_contactPlaneSourceMember = member;
_contactPlaneSourceLine = line;
}
/// <summary>
/// <c>member:line</c> of the last site to assert a contact plane since
/// <see cref="BeginContactPlaneAttribution"/>, or <c>"none"</c>.
/// </summary>
public static string ContactPlaneSource =>
_contactPlaneSourceMember is { Length: > 0 } member
? string.Concat(
member,
":",
_contactPlaneSourceLine.ToString(
System.Globalization.CultureInfo.InvariantCulture))
: "none";
/// <summary>
/// Classify what is under the body. Pure function so the live probe and any
/// offline reader agree on the vocabulary.
/// <list type="bullet">
/// <item><c>none</c> — no contact plane: the body is unsupported.</item>
/// <item><c>terrain</c> — the contact plane sits at the terrain's height
/// AND shares its tilt.</item>
/// <item><c>object</c> — the contact plane sits clear of the terrain: some
/// collision surface other than the ground is the support.</item>
/// <item><c>coplanar-tilt-mismatch</c> — same height, different tilt.
/// Reported as its own answer rather than folded into either, because
/// it is exactly what a collision mesh laid flat against the ground
/// would look like and guessing between the two would be the third
/// unverified diagnosis this campaign.</item>
/// <item><c>no-terrain</c> — no outdoor terrain under this XY (indoors,
/// or the landblock is not resident): the comparison is unavailable
/// and is said so rather than defaulted.</item>
/// </list>
/// </summary>
public static string ClassifySupport(
bool contactPlaneValid,
bool terrainSampled,
float contactPlaneZAtXY,
float contactPlaneNormalZ,
float terrainZ,
float terrainNormalZ)
{
if (!contactPlaneValid) return "none";
if (!terrainSampled) return "no-terrain";
bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ;
bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ;
if (sameHeight && sameTilt) return "terrain";
if (sameHeight) return "coplanar-tilt-mismatch";
return "object";
}
/// <summary>
/// Evaluate a plane's height at a given XY. Returns <see langword="false"/>
/// when the plane is near-vertical, where a height is not defined — a wall
/// is never a floor, and reporting a huge number for one would read as a
/// displaced surface.
/// </summary>
public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z)
{
float nz = plane.Normal.Z;
if (MathF.Abs(nz) < 1e-4f)
{
z = float.NaN;
return false;
}
z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz;
return true;
}
/// <summary>
/// One <c>[support]</c> line. Self-guards on
/// <see cref="ProbeSupportEnabled"/>.
///
/// <para>
/// Volume control: per mover, the line re-emits IMMEDIATELY on any change
/// in the state signature — the support classification, the ok / contact /
/// walkable / stalled bits, a 0.1 m change in the body's height, or a
/// 0.1 m change in its height above terrain — and otherwise at most once
/// per 250 ms. A body falling through geometry therefore produces a line
/// every 10 cm of descent, and a body standing still produces four lines a
/// second. Nothing is aggregated away.
/// </para>
///
/// <para>
/// <paramref name="moverId"/> is never omitted:
/// <c>feedback_probe_identity_attribution</c> — a per-entity probe without
/// an identity produced a wrong root cause once already, and this capture
/// deliberately covers several bodies at once.
/// </para>
/// </summary>
public static void LogSupport(
uint moverId,
bool isPlayer,
Vector3 inPos,
uint inCell,
Vector3 targetPos,
Vector3 outPos,
uint outCell,
bool ok,
bool groundedIn,
bool contact,
bool onWalkable,
bool contactPlaneValid,
Plane contactPlane,
uint contactPlaneCellId,
bool contactPlaneIsWater,
string contactPlaneSource,
bool lastKnownValid,
Plane lastKnownPlane,
bool terrainSampled,
float terrainZ,
Vector3 terrainNormal,
uint terrainCellId,
bool terrainIsWater,
bool walkablePolygon,
bool lastWalkablePolygon,
float stepUpHeight,
float stepDownHeight,
Vector3 velocity)
{
if (!ProbeSupportEnabled) return;
float cpZ = float.NaN;
bool cpZDefined = contactPlaneValid
&& TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ);
if (!cpZDefined) cpZ = float.NaN;
float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN;
float terrNz = terrainSampled ? terrainNormal.Z : float.NaN;
string support = ClassifySupport(
contactPlaneValid && cpZDefined,
terrainSampled,
cpZ,
cpNz,
terrainZ,
terrNz);
float commanded = Vector3.Distance(inPos, targetPos);
float moved = Vector3.Distance(inPos, outPos);
// "The body was told to move and did not." The 1 cm floor is the
// resolver's own no-op scale, not a tuned threshold.
bool stalled = commanded > 0.01f && moved <= 0.01f;
float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN;
float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN;
long now = Environment.TickCount64;
long signature = support.GetHashCode();
signature = signature * 31 + (ok ? 1 : 0);
signature = signature * 31 + (groundedIn ? 1 : 0);
signature = signature * 31 + (contact ? 1 : 0);
signature = signature * 31 + (onWalkable ? 1 : 0);
signature = signature * 31 + (contactPlaneValid ? 1 : 0);
signature = signature * 31 + (stalled ? 1 : 0);
signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f);
signature = signature * 31 + (float.IsNaN(zAboveTerrain)
? 0
: (long)MathF.Floor(zAboveTerrain * 10f));
lock (_supportGate)
{
if (_supportSeen.TryGetValue(moverId, out var prev)
&& prev.Signature == signature
&& now - prev.Ms < 250)
{
return;
}
_supportSeen[moverId] = (now, signature);
}
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " +
"in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " +
"tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " +
"ok={15} cmd={16:F3} moved={17:F3} stalled={18} " +
"groundedIn={19} contact={20} onWalkable={21} " +
"cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " +
"cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " +
"cpZatOut={32:F3} " +
"lkcpValid={33} lkcpNz={34:F4} " +
"terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " +
"terrCell=0x{39:X8} terrWater={40} " +
"zAboveTerr={41:F3} cpAboveTerr={42:F3} " +
"walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " +
"vel=({47:F3},{48:F3},{49:F3})",
moverId, isPlayer, now, support,
inPos.X, inPos.Y, inPos.Z, inCell,
targetPos.X, targetPos.Y, targetPos.Z,
outPos.X, outPos.Y, outPos.Z, outCell,
ok, commanded, moved, stalled,
groundedIn, contact, onWalkable,
contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater,
contactPlaneValid ? contactPlane.Normal.X : float.NaN,
contactPlaneValid ? contactPlane.Normal.Y : float.NaN,
cpNz, cpNz, PhysicsGlobals.FloorZ,
contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ,
cpZ,
lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN,
terrainSampled, terrainZ, terrNz,
terrainSampled && terrNz >= PhysicsGlobals.FloorZ,
terrainCellId, terrainIsWater,
zAboveTerrain, cpAboveTerrain,
walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight,
velocity.X, velocity.Y, velocity.Z));
}
/// <summary>
/// Ask whether <c>[geom]</c> has already been emitted for this GfxObj.
/// The line is a property of the ASSET, not of any moment, so once per
/// process is the whole story and re-emitting it would bury the
/// <c>[support]</c> stream.
/// </summary>
public static bool ShouldLogGeometry(uint gfxObjId)
{
if (!ProbeSupportEnabled) return false;
lock (_supportGate)
{
return _geomSeen.Add(gfxObjId);
}
}
/// <summary>
/// One <c>[geom]</c> line: is this object's collision geometry where its
/// visual geometry is? Caller MUST have claimed the id through
/// <see cref="ShouldLogGeometry"/>.
///
/// <para>
/// The verdict vocabulary, and what each one settles:
/// <list type="bullet">
/// <item><c>no-physics-bsp</c> / <c>empty-physics-bsp</c> — the object
/// has no collision polygons at all. Everything a body does around it
/// follows from that one fact and no movement-side theory is needed.</item>
/// <item><c>no-visual-bounds</c> — the comparison could not be made. Said
/// out loud rather than silently treated as agreement.</item>
/// <item><c>displaced</c> — collision and visual are the same size but
/// sit in different places. This is the working hypothesis, and this
/// token is the only thing that confirms it.</item>
/// <item><c>extent-mismatch</c> — same place, different size.</item>
/// <item><c>coincident</c> — collision and visual agree. This REFUTES the
/// working hypothesis for this object, and the cause is then on the
/// movement side (terrain support, or the transition itself).</item>
/// </list>
/// </para>
/// </summary>
public static void LogGeometry(
uint gfxObjId,
uint entityId,
int bspNodeCount,
int bspPolygonCount,
int bspVertexCount,
Vector3 rootSphereOrigin,
float rootSphereRadius,
bool physicsBoundsValid,
Vector3 physicsMin,
Vector3 physicsMax,
bool visualBoundsValid,
Vector3 visualMin,
Vector3 visualMax,
float visualRadius,
Vector3 entityWorldPosition,
float entityScale,
float registeredRadius)
{
Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero;
Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero;
Vector3 physCentre = physicsBoundsValid
? (physicsMin + physicsMax) * 0.5f
: Vector3.Zero;
Vector3 visCentre = visualBoundsValid
? (visualMin + visualMax) * 0.5f
: Vector3.Zero;
float centreDelta = physicsBoundsValid && visualBoundsValid
? Vector3.Distance(physCentre, visCentre)
: float.NaN;
// Tolerances are deliberately loose: this line answers "same place,
// same size?" at the scale of a rock formation, not to the millimetre.
// A physics hull is a coarse stand-in for the render mesh, so a
// half-metre of centre drift or a 2x extent ratio is normal; what this
// is looking for is the pathological case.
float centreTolerance = visualBoundsValid
? MathF.Max(0.5f, visualRadius * 0.25f)
: 0.5f;
bool extentMismatch = false;
if (physicsBoundsValid && visualBoundsValid)
{
for (int axis = 0; axis < 3; axis++)
{
float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z;
float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z;
// Flat axes (a floor plate) legitimately have ~0 extent in one
// dimension on both sides; only compare where the visual has
// real size.
if (v < 0.1f) continue;
float ratio = p / v;
if (ratio is < 0.5f or > 2.0f) extentMismatch = true;
}
}
string verdict =
bspNodeCount == 0 ? "no-physics-bsp"
: bspPolygonCount == 0 ? "empty-physics-bsp"
: !visualBoundsValid ? "no-visual-bounds"
: !physicsBoundsValid ? "no-physics-bounds"
: centreDelta > centreTolerance ? "displaced"
: extentMismatch ? "extent-mismatch"
: "coincident";
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " +
"bspNodes={4} bspPolys={5} bspVerts={6} " +
"rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " +
"physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " +
"physExt=({18:F3},{19:F3},{20:F3}) " +
"visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " +
"visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " +
"centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " +
"objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " +
"physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]",
gfxObjId, verdict, entityId, Environment.TickCount64,
bspNodeCount, bspPolygonCount, bspVertexCount,
rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z,
rootSphereRadius, registeredRadius,
physicsMin.X, physicsMin.Y, physicsMin.Z,
physicsMax.X, physicsMax.Y, physicsMax.Z,
physExtent.X, physExtent.Y, physExtent.Z,
visualMin.X, visualMin.Y, visualMin.Z,
visualMax.X, visualMax.Y, visualMax.Z,
visExtent.X, visExtent.Y, visExtent.Z, visualRadius,
centreDelta, centreTolerance, extentMismatch,
entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z,
entityScale,
// Rotation is NOT applied to these two world Z ranges: an
// axis-aligned box is not rotation-invariant, so a rotated object
// would report a box that is merely indicative. Both sides get the
// SAME treatment, so their AGREEMENT (the thing being measured)
// stays exact regardless.
entityWorldPosition.Z + physicsMin.Z * entityScale,
entityWorldPosition.Z + physicsMax.Z * entityScale,
entityWorldPosition.Z + visualMin.Z * entityScale,
entityWorldPosition.Z + visualMax.Z * entityScale));
}
/// <summary>
/// Resolve the collision-vs-visual comparison for one GfxObj straight from
/// the SAME prepared assets the resolver itself queries, and emit its
/// <c>[geom]</c> line. Going through the production accessors is the point:
/// AP-156's lesson was that a probe reading geometry by a second route can
/// report a shape the registry never emitted. Caller MUST have claimed the
/// id through <see cref="ShouldLogGeometry"/>.
///
/// <para>
/// The physics box is measured over the vertices of the polygons the BSP
/// actually indexes, not over the whole polygon table — a table can carry
/// rows no node references, and including those would report collision
/// geometry that no query can ever reach.
/// </para>
/// </summary>
public static void LogGeometryFromAssets(
uint gfxObjId,
uint entityId,
FlatGfxObjCollisionAsset? flat,
GfxObjVisualBounds? visual,
Vector3 entityWorldPosition,
float entityScale,
float registeredRadius)
{
int nodeCount = 0;
int polygonCount = 0;
int vertexCount = 0;
Vector3 rootOrigin = Vector3.Zero;
float rootRadius = 0f;
bool physBoundsValid = false;
var physMin = new Vector3(float.PositiveInfinity);
var physMax = new Vector3(float.NegativeInfinity);
FlatPhysicsBsp? bsp = flat?.PhysicsBsp;
if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0)
{
nodeCount = bsp.Nodes.Length;
rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin;
rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius;
FlatPolygonTable table = bsp.PolygonTable;
foreach (FlatPhysicsBspNode node in bsp.Nodes)
{
FlatIndexRange range = node.PolygonIndexRange;
for (int i = range.Start; i < range.EndExclusive; i++)
{
int polygonIndex = bsp.PolygonIndexStream[i];
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
polygonCount++;
FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange;
for (int v = vertices.Start; v < vertices.EndExclusive; v++)
{
Vector3 p = table.Vertices[v];
vertexCount++;
physMin = Vector3.Min(physMin, p);
physMax = Vector3.Max(physMax, p);
physBoundsValid = true;
}
}
}
}
if (!physBoundsValid)
{
physMin = Vector3.Zero;
physMax = Vector3.Zero;
}
LogGeometry(
gfxObjId: gfxObjId,
entityId: entityId,
bspNodeCount: nodeCount,
bspPolygonCount: polygonCount,
bspVertexCount: vertexCount,
rootSphereOrigin: rootOrigin,
rootSphereRadius: rootRadius,
physicsBoundsValid: physBoundsValid,
physicsMin: physMin,
physicsMax: physMax,
visualBoundsValid: visual is not null,
visualMin: visual?.Min ?? Vector3.Zero,
visualMax: visual?.Max ?? Vector3.Zero,
visualRadius: visual?.Radius ?? 0f,
entityWorldPosition: entityWorldPosition,
entityScale: entityScale,
registeredRadius: registeredRadius);
}
/// <summary>
/// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic).
/// Emits one <c>[tp-probe]</c> line per teleport-pipeline event with a
@ -1356,6 +2138,20 @@ public static class PhysicsDiagnostics
ProbePlacementFailEnabled = false;
ProbeSweptEnabled = false;
ProbeStepWalkEnabled = false;
ProbeReachEnabled = false;
lock (_reachGate)
{
_reachSeenObj.Clear();
_reachSeenQuery.Clear();
}
ProbeSupportEnabled = false;
_contactPlaneSourceMember = null;
_contactPlaneSourceLine = 0;
lock (_supportGate)
{
_supportSeen.Clear();
_geomSeen.Clear();
}
ProbeTeleportEnabled = false;
ProbeRemoteTeleportEnabled = false;
ProbeRemoteLandingEnabled = false;

View file

@ -1913,6 +1913,15 @@ public sealed class PhysicsEngine
? PhysicsResolveCapture.Snapshot(body)
: null;
// #337 (2026-08-06 — TEMPORARY): arm the [support] probe's
// contact-plane provenance latch for this resolve, ahead of everything
// including the carried-plane seed below. The seed is itself one of
// the ten sites that assert a plane, so it stamps its own name and a
// capture can read `cpSrc=ResolveWithTransition:<line>` as "carried
// from the body, nothing re-derived it this resolve" without needing a
// sentinel value for that case. No-op when the probe is off.
PhysicsDiagnostics.BeginContactPlaneAttribution();
var transition = RentTransition();
try
{
@ -2299,6 +2308,67 @@ public sealed class PhysicsEngine
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
}
// #337 [support] probe (2026-08-06 — TEMPORARY, strip with the
// physics-probe family). Runs for EVERY body, not just the player:
// a corpse sinking through geometry is a plain physics body with
// no player-specific logic, so it is the cheapest possible control
// on whether the movement code or the geometry is at fault, and it
// is invisible to any player-filtered probe.
//
// The terrain sample below is INDEPENDENT of whatever the sweep
// decided — it asks the landblock directly what the ground height
// is under the body's own out-XY. Pairing that with the contact
// plane's height at the same XY is what separates "terrain is
// holding this body up" from "some object surface is". Read-only:
// SampleTerrainWalkable takes no locks, mutates nothing, and is
// not on the resolve's committed path.
if (PhysicsDiagnostics.ProbeSupportEnabled)
{
Vector3 outPos = sp.CheckPos;
TerrainWalkableSample? terrain =
SampleTerrainWalkable(outPos.X, outPos.Y);
bool terrainSampled = terrain.HasValue
&& PhysicsDiagnostics.TryPlaneZAt(
terrain.Value.Plane, outPos.X, outPos.Y, out _);
float terrainZ = float.NaN;
if (terrainSampled)
{
PhysicsDiagnostics.TryPlaneZAt(
terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ);
}
PhysicsDiagnostics.LogSupport(
moverId: movingEntityId,
isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0,
inPos: currentPos,
inCell: cellId,
targetPos: targetPos,
outPos: outPos,
outCell: sp.CheckCellId,
ok: ok,
groundedIn: isOnGround,
contact: transition.ObjectInfo.Contact,
onWalkable: transition.ObjectInfo.OnWalkable,
contactPlaneValid: ci.ContactPlaneValid,
contactPlane: ci.ContactPlane,
contactPlaneCellId: ci.ContactPlaneCellId,
contactPlaneIsWater: ci.ContactPlaneIsWater,
contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource,
lastKnownValid: ci.LastKnownContactPlaneValid,
lastKnownPlane: ci.LastKnownContactPlane,
terrainSampled: terrainSampled,
terrainZ: terrainZ,
terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero,
terrainCellId: terrain?.CellId ?? 0u,
terrainIsWater: terrain?.IsWater ?? false,
walkablePolygon: sp.HasWalkablePolygon,
lastWalkablePolygon: sp.HasLastWalkablePolygon,
stepUpHeight: stepUpHeight,
stepDownHeight: stepDownHeight,
velocity: body?.Velocity ?? Vector3.Zero);
}
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.

View file

@ -430,12 +430,29 @@ public sealed class ShadowObjectRegistry
///
/// <para>
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
/// per OBJECT with its full sphere set, not per part). The flood spheres
/// follow <c>CPhysicsObj::calc_cross_cells</c>' own EXCLUSIVE priority —
/// physics-BSP parts, else CylSpheres, else the remaining shapes — see
/// <see cref="BuildFloodSpheres"/> for the disassembly. A BSP part
/// contributes its ROOT BOUNDING SPHERE placed at its real center
/// (<see cref="ShadowShape.BoundsCenter"/>), not at the part origin.
/// per OBJECT, not per part). WHICH flood is retail's own exclusive
/// dispatch on <c>HAS_PHYSICS_BSP_PS</c>
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230,
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c>):
/// </para>
/// <list type="bullet">
/// <item>BSP-bearing → <c>find_bbox_cell_list</c> @0x00510fc0, ported as
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Each part
/// contributes its authored BOUNDING BOX
/// (<see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c>), and the
/// outdoor expansion is the FILLED CELL RECTANGLE that box spans —
/// crossing landblock boundaries freely. Before #334 these objects
/// were routed through the sphere flood below, whose outdoor reach
/// is a fixed 3×3 (±24 m) regardless of radius, so any formation
/// wider than one land cell simply was not registered in its outer
/// cells.</item>
/// <item>otherwise → <see cref="BuildFloodSpheres"/> +
/// <see cref="CellTransit.BuildShadowCellSet"/>, retail's
/// cylsphere and sorting-sphere branches, byte-identical to before
/// #334 for every object that legitimately is spherical.</item>
/// </list>
/// <para>
/// Every shape row is then written into every flooded cell, mirroring
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
/// </para>
@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
// Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
// (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
// physics BSP, and calc_cross_cells (0x00515285) branches on that bit.
// AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and
// "is a BSP object" coincide exactly as the cached retail flag does.
bool hasBsp = false;
for (int i = 0; i < shapes.Count; i++)
{
if (shapes[i].CollisionType == ShadowCollisionType.BSP)
{
hasBsp = true;
break;
}
}
IReadOnlyList<uint> cellSet;
if (hasBsp)
{
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seed, partBoxes, partSpheres, isStatic);
}
else
{
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
}
if (cellSet.Count == 0) return;
DeregisterCore(entityId, publishMutation: false);
@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry
}
/// <summary>
/// Retail cross-cell dispatch, <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230, in retail's own priority order:
/// Flood spheres for an object with NO physics BSP — retail's cylsphere
/// and sorting-sphere branches of <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230, both of which sit BELOW the <c>HAS_PHYSICS_BSP_PS</c> jump
/// at <c>0x0051528f jne 0x515305</c> and are unreachable from it:
///
/// <list type="number">
/// <item>BSP-bearing (<c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c>) → <c>CPhysicsObj::find_bbox_cell_list</c>
/// @0x00510fc0. The cylsphere and sorting-sphere branches are BOTH below
/// that jump and unreachable from it. <c>find_bbox_cell_list</c> adds the
/// object's own cell and then walks the PART ARRAY through
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
/// <c>[vtbl+0x7c]</c> dispatch, whose EnvCell body
/// (<c>CEnvCell::find_transit_cells</c> @0x0052cae0) tests each part's
/// <c>CGfxObj::physics_sphere</c> — the BSP root bounding sphere, center
/// transformed through the part's own Position — against the cell's
/// portal planes. acdream floods from those same per-part spheres
/// (<see cref="ShadowShape.BoundsCenter"/> + <see cref="ShadowShape.Radius"/>)
/// rather than walking portal planes per part; the sphere set is exact,
/// the traversal is the sphere-vs-portal one (AP-156).</item>
/// <item>else cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
/// <item>cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
/// each contributes one sphere at its world BASE point with the cylinder
/// radius, capped at 10.</item>
@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry
/// </list>
///
/// <para>
/// The BSP-first rule is redundant for every shape list acdream produces
/// today — <see cref="ShadowShapeBuilder.FromSetup"/> dispatches at
/// emission (AP-152) and both landblock-static publishers emit
/// homogeneous lists — exactly as
/// <c>Transition.BspOnlyDispatch</c> is redundant at the query site. It is
/// kept because retail genuinely dispatches here, and because a producer
/// that handed this method a mixed list would otherwise flood a
/// BSP-bearing object from its primitive and silently place it in the
/// wrong shadow cells (the #98 / #168 symptom class).
/// #334: there is no BSP arm here any more, and there must not be one.
/// The BSP branch is a structurally different algorithm over BOXES
/// (<see cref="CellTransit.BuildShadowCellSetFromParts"/>), and
/// <see cref="RegisterMultiPart"/> routes to it before this method is
/// reached. The arm this method used to carry — "a BSP part contributes
/// its ROOT BOUNDING SPHERE placed at its real center" — described
/// retail's INDOOR portal reject, not its outdoor expansion, and using it
/// for both is what capped every BSP object's outdoor reach at a 3×3
/// neighbourhood. A BSP shape reaching this method would be a dispatch
/// bug; it is skipped rather than flooded from, so it cannot silently
/// produce the wrong cells (the #98 / #168 symptom class).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry
const int RetailSphereCap = 10;
var spheres = new List<DatReaderWriter.Types.Sphere>();
bool anyBsp = false;
bool anyCyl = false;
foreach (var s in shapes)
{
if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true;
else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
}
// Retail's branch, chosen once: BSP-bbox, else cylspheres, else the
// sorting sphere (which acdream approximates with the remaining
// shapes' bounding spheres — AP-157).
ShadowCollisionType? only =
anyBsp ? ShadowCollisionType.BSP
: anyCyl ? ShadowCollisionType.Cylinder
: null;
// Retail's branch, chosen once: cylspheres, else the sorting sphere
// (which acdream approximates with the Sphere shapes — AP-157).
ShadowCollisionType only =
anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere;
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
@ -667,41 +695,29 @@ public sealed class ShadowObjectRegistry
// fixed static-buffer capacity (the destination array at
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy.
//
// BSP branch: NO CAP, and this is a retail port. find_bbox_cell_list
// @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 ->
// CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded
// only by num_parts. Clamping it dropped parts 11..N out of the flood
// entirely: 7 installed Setups carry more than 10 physics-BSP parts
// (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair
// runs, fences, rock clusters — routinely do.
//
// only == null (the sorting-sphere branch): int.MaxValue is NOT a
// retail port and the addresses above do not justify it. Retail's
// overload @0x0052b990 pushes a literal 1 (0x0052b9d6 push 1) and
// floods from ONE authored CSetup::sorting_sphere. acdream floods from
// every Sphere shape instead — a different DAT field with a different
// cardinality, which is AP-157, filed and open. Capping at 1 HERE would
// not move toward retail: it would take Spheres[0], which is not the
// sorting sphere. int.MaxValue keeps the substitution in its safe
// (over-inclusive) direction until AP-157 ports the real field. Inert
// over installed data — max 5 Spheres on any Setup (0x020016F7).
// Sorting-sphere branch: int.MaxValue is NOT a retail port and the
// addresses above do not justify it. Retail's overload @0x0052b990
// pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored
// CSetup::sorting_sphere. acdream floods from every Sphere shape
// instead — a different DAT field with a different cardinality, which
// is AP-157, filed and open. Capping at 1 HERE would not move toward
// retail: it would take Spheres[0], which is not the sorting sphere.
// int.MaxValue keeps the substitution in its safe (over-inclusive)
// direction until AP-157 ports the real field. Inert over installed
// data — max 5 Spheres on any Setup (0x020016F7).
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
foreach (var s in shapes)
{
if (only is { } required && s.CollisionType != required)
if (s.CollisionType != only)
continue;
if (spheres.Count >= cap)
break;
// Place the sphere where the GEOMETRY is, not where the part
// origin is. Composed exactly as the ShadowEntry rows below are
// (partWorldPos / partWorldRot), then offset by the shape's own
// BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0
// transforms CGfxObj::physics_sphere's center through the part's
// Position at [part+0x30] before reading its radius at
// 0x0052cb65. Primitives carry BoundsCenter == Zero because their
// LocalPosition already is their center.
// A primitive's LocalPosition already IS its centre, so
// BoundsCenter is Zero; the composition is kept identical to the
// emitted ShadowEntry rows so the flood and the geometry can never
// disagree about where the shape is.
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry
return spheres;
}
/// <summary>
/// #334: the per-part world-placed authored boxes retail's
/// <c>CLandCell::add_all_outside_cells</c> @0x00533360 divides by
/// <c>square_length</c>. Composed exactly as the emitted
/// <see cref="ShadowEntry"/> rows are, so the flood rectangle and the
/// collision geometry describe the same placement.
/// </summary>
private static List<ShadowPartBox> BuildFloodPartBoxes(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
var boxes = new List<ShadowPartBox>(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot));
}
return boxes;
}
/// <summary>
/// The per-part BSP ROOT bounding spheres retail's part-array
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 loads at
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms through the part's own
/// Position (<c>0x0052cb4c</c> / <c>Position::localtolocal</c>) and reads
/// the radius from at <c>0x0052cb65 fadd [esi+0xc]</c>.
///
/// <para>
/// These drive ONLY the indoor half of the BSP flood and the outdoor
/// building bridge (<c>CEnvCell::check_building_transit</c> @0x0052c5d0),
/// which still use the sphere traversal — the AP-159 residual. The
/// outdoor expansion uses <see cref="BuildFloodPartBoxes"/> and never
/// these. No cap: <c>find_bbox_cell_list</c> walks every part, bounded
/// only by <c>num_parts</c> (7 installed Setups carry more than 10
/// physics-BSP parts, max 49 on Setup 0x02001A91).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildBspPartSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
var spheres = new List<DatReaderWriter.Types.Sphere>(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot),
Radius = s.Radius,
});
}
return spheres;
}
/// <summary>
/// Derive the outdoor landcell id under a world position — the implicit
/// seed for landblock-baked statics registered without a cell id

View file

@ -0,0 +1,171 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// One physics-BSP part's flood geometry, resolved as ONE value: the part
/// GfxObj's physics-BSP root bounding sphere AND the axis-aligned box of its
/// vertex array, both in the GfxObj's own unscaled frame.
///
/// <para>
/// The pairing is the AP-156 invariant applied a second time. Retail's cell
/// membership reads BOTH — <c>CEnvCell::find_transit_cells</c> @0x0052cae0
/// takes <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) for its cheap
/// portal-plane reject, and <c>CLandCell::add_all_outside_cells</c> @0x00533360
/// takes <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
/// (<c>&amp;gfxobj-&gt;gfx_bound_box</c>) for the outdoor extent walk. A resolver
/// that answered only one of the two would leave the other call site to
/// synthesize a substitute, which is exactly how the sphere came to be placed
/// at the part origin (AP-156) and how #334's outdoor rectangle came to be a
/// fixed 3×3.
/// </para>
///
/// <para>
/// <see cref="BoxMin"/>/<see cref="BoxMax"/> come from
/// <c>FlatGfxObjVisualBounds</c>, which
/// <c>FlatCollisionAssetBuilder.FlattenGfxObj</c> computes with
/// <c>PhysicsDataCache.ComputeVisualBounds(source.VertexArray)</c> — the exact
/// <c>CGfxObj::init_end</c> @0x00534200 computation (seed min=max=vertices[0],
/// then <c>BBox::AdjustBBox</c> over every vertex of the render vertex array,
/// which is also the array the physics polygons index into).
/// </para>
/// </summary>
public readonly record struct ShadowPartGeometry
{
private ShadowPartGeometry(
FlatCollisionSphere sphere,
Vector3 boxMin,
Vector3 boxMax)
{
Sphere = sphere;
BoxMin = boxMin;
BoxMax = boxMax;
}
/// <summary>Retail <c>CGfxObj::physics_sphere</c> — the physics BSP root
/// bounding sphere, origin included, unscaled.</summary>
public FlatCollisionSphere Sphere { get; }
/// <summary>Retail <c>CGfxObj::gfx_bound_box.m_vMin</c>, unscaled.</summary>
public Vector3 BoxMin { get; }
/// <summary>Retail <c>CGfxObj::gfx_bound_box.m_vMax</c>, unscaled.</summary>
public Vector3 BoxMax { get; }
/// <summary>
/// Pairs the two. <paramref name="visualBounds"/> is the prepared
/// package's <c>FlatGfxObjVisualBounds</c>; when it is absent (graph-only
/// fixtures, and prepared assets baked without the field) the box falls
/// back to the sphere's own axis-aligned bound, which contains every
/// physics polygon vertex the sphere contains and keeps the substitution
/// in the over-inclusive direction retail itself uses (§1.8 of
/// <c>docs/research/2026-08-06-334-contract.md</c>). The fallback lives
/// HERE so no call site can observe a half-populated value.
/// </summary>
public static ShadowPartGeometry Create(
FlatCollisionSphere sphere,
FlatGfxObjVisualBounds? visualBounds)
{
if (visualBounds is { } bounds)
return new ShadowPartGeometry(sphere, bounds.Min, bounds.Max);
var extent = new Vector3(sphere.Radius);
return new ShadowPartGeometry(
sphere,
sphere.Origin - extent,
sphere.Origin + extent);
}
}
/// <summary>
/// One physics-BSP part's world-placed bounding box — the per-part input to
/// retail's outdoor extent walk.
///
/// <para>
/// Retail's <c>CLandCell::add_all_outside_cells</c> @0x00533360 calls
/// <c>BBox::LocalToGlobal(part-&gt;gfxobj-&gt;gfx_bound_box, part-&gt;pos,
/// cell0-&gt;pos)</c> (@0x00533527) per part, so the box it divides by
/// <c>square_length</c> is the part's authored box re-fit through the part's
/// own placement. <see cref="LocalMin"/>/<see cref="LocalMax"/> are that
/// authored box (entity-scaled); <see cref="WorldPosition"/>/
/// <see cref="WorldRotation"/> are the part placement
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> composes for the
/// <c>ShadowEntry</c> rows, so the flood and the geometry can never disagree
/// about where the part is.
/// </para>
///
/// <para>
/// CONSTRUCTION IS BY FACTORY ONLY: min and max arrive together, from one
/// <see cref="ShadowShape"/>, so no future producer can carry one and drop the
/// other.
/// </para>
/// </summary>
public readonly record struct ShadowPartBox
{
private ShadowPartBox(
Vector3 localMin,
Vector3 localMax,
Vector3 worldPosition,
Quaternion worldRotation)
{
LocalMin = localMin;
LocalMax = localMax;
WorldPosition = worldPosition;
WorldRotation = worldRotation;
}
/// <summary>Authored box minimum in the part's own frame, entity-scaled.</summary>
public Vector3 LocalMin { get; }
/// <summary>Authored box maximum in the part's own frame, entity-scaled.</summary>
public Vector3 LocalMax { get; }
/// <summary>The part's world placement — retail <c>CPhysicsPart::pos</c>.</summary>
public Vector3 WorldPosition { get; }
/// <summary>The part's world orientation — retail <c>CPhysicsPart::pos</c>.</summary>
public Quaternion WorldRotation { get; }
/// <summary>
/// Composes the part's world placement exactly as
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> composes the
/// emitted <c>ShadowEntry</c>'s.
/// </summary>
public static ShadowPartBox FromShape(
in ShadowShape shape,
Vector3 entityWorldPosition,
Quaternion entityWorldRotation)
=> new(
shape.LocalBoundsMin,
shape.LocalBoundsMax,
entityWorldPosition
+ Vector3.Transform(shape.LocalPosition, entityWorldRotation),
entityWorldRotation * shape.LocalRotation);
/// <summary>
/// Retail <c>BBox::LocalToGlobal</c> @0x005b2120 — a proper EIGHT-CORNER
/// re-fit, not a min/max transform: transform <c>min</c>, seed both
/// corners from it, transform the other seven and <c>AdjustBBox</c> each.
/// A rotated box therefore GROWS, conservatively, which is the direction
/// retail deliberately errs in.
/// </summary>
/// <param name="frameOrigin">World origin of the destination frame —
/// retail's <c>cell0-&gt;pos</c>, i.e. the landblock the extent walk
/// anchors on.</param>
public void RefitTo(Vector3 frameOrigin, out Vector3 min, out Vector3 max)
{
Vector3 offset = WorldPosition - frameOrigin;
min = new Vector3(float.MaxValue);
max = new Vector3(float.MinValue);
for (int corner = 0; corner < 8; corner++)
{
var local = new Vector3(
(corner & 1) == 0 ? LocalMin.X : LocalMax.X,
(corner & 2) == 0 ? LocalMin.Y : LocalMax.Y,
(corner & 4) == 0 ? LocalMin.Z : LocalMax.Z);
Vector3 world = Vector3.Transform(local, WorldRotation) + offset;
min = Vector3.Min(min, world);
max = Vector3.Max(max, world);
}
}
}

View file

@ -20,12 +20,13 @@ namespace AcDream.Core.Physics;
/// CONSTRUCTION IS BY FACTORY ONLY (<see cref="Bsp"/>, <see cref="Cylinder"/>,
/// <see cref="Sphere"/>) and the constructor is private. That is the AP-156
/// invariant expressed at the type rather than only at the producer: a BSP
/// shape's radius and its bounding-sphere CENTRE arrive as one
/// <see cref="FlatCollisionSphere"/> value and are scaled together inside
/// <see cref="Bsp"/>, so no call site — present or future — can take the
/// radius while dropping the origin. That split is exactly what produced
/// AP-156, and with the old public 7-argument constructor a new BSP producer
/// could have reintroduced it silently and green.
/// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX
/// arrive as one <see cref="ShadowPartGeometry"/> value and are scaled together
/// inside <see cref="Bsp"/>, so no call site — present or future — can take one
/// while dropping another. Those splits are exactly what produced AP-156 (the
/// centre dropped) and #334 (the box never resolved at all); with a public
/// positional constructor a new BSP producer could reintroduce either silently
/// and green.
/// </para>
/// </summary>
public readonly record struct ShadowShape
@ -38,7 +39,9 @@ public readonly record struct ShadowShape
ShadowCollisionType collisionType,
float radius,
float cylHeight,
Vector3 boundsCenter)
Vector3 boundsCenter,
Vector3 localBoundsMin,
Vector3 localBoundsMax)
{
GfxObjId = gfxObjId;
LocalPosition = localPosition;
@ -48,6 +51,8 @@ public readonly record struct ShadowShape
Radius = radius;
CylHeight = cylHeight;
BoundsCenter = boundsCenter;
LocalBoundsMin = localBoundsMin;
LocalBoundsMax = localBoundsMax;
}
/// <summary>Source GfxObj id, for the BSP walk and for diagnostics.</summary>
@ -107,27 +112,51 @@ public readonly record struct ShadowShape
public Vector3 BoundsCenter { get; }
/// <summary>
/// One physics-BSP part. <paramref name="localBounds"/> is the part
/// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame,
/// unscaled — retail's <c>CGfxObj::physics_sphere</c>. Radius and centre
/// are scaled together here, which is the whole point of taking them as
/// one value.
/// The shape's AXIS-ALIGNED BOX in the same local frame as
/// <see cref="BoundsCenter"/>, already entity-scaled. For a BSP shape this
/// is retail's <c>CGfxObj::gfx_bound_box</c> — the AABB of the GfxObj's
/// vertex array, which <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
/// returns and which <c>CLandCell::add_all_outside_cells</c> @0x00533360
/// divides by <c>square_length</c> to build the outdoor cell rectangle
/// (#334). Primitive shapes carry their own radius/height box; they never
/// reach that path, because <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230 routes only <c>HAS_PHYSICS_BSP_PS</c> objects to
/// <c>find_bbox_cell_list</c>.
/// </summary>
public Vector3 LocalBoundsMin { get; }
/// <inheritdoc cref="LocalBoundsMin"/>
public Vector3 LocalBoundsMax { get; }
/// <summary>
/// One physics-BSP part. <paramref name="localGeometry"/> carries the part
/// GfxObj's physics-BSP ROOT bounding sphere AND its vertex-array box in
/// the GfxObj's OWN frame, unscaled — retail's
/// <c>CGfxObj::physics_sphere</c> and <c>CGfxObj::gfx_bound_box</c>.
/// Sphere radius, sphere centre, and both box corners are scaled together
/// here, which is the whole point of taking them as one value: retail
/// reads the sphere for the indoor portal reject and the box for the
/// outdoor extent walk, and a producer that supplied one without the
/// other would silently force a substitute at the other call site
/// (AP-156, then #334).
/// </summary>
public static ShadowShape Bsp(
uint gfxObjId,
Vector3 localPosition,
Quaternion localRotation,
float scale,
FlatCollisionSphere localBounds)
ShadowPartGeometry localGeometry)
=> new(
gfxObjId,
localPosition,
localRotation,
scale,
ShadowCollisionType.BSP,
localBounds.Radius * scale,
localGeometry.Sphere.Radius * scale,
0f,
localBounds.Origin * scale);
localGeometry.Sphere.Origin * scale,
localGeometry.BoxMin * scale,
localGeometry.BoxMax * scale);
/// <summary>
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the
@ -148,7 +177,9 @@ public readonly record struct ShadowShape
ShadowCollisionType.Cylinder,
radius,
cylHeight,
Vector3.Zero);
Vector3.Zero,
new Vector3(-radius, -radius, 0f),
new Vector3(radius, radius, cylHeight));
/// <summary>
/// One Setup Sphere. <paramref name="localPosition"/> already IS the
@ -168,5 +199,7 @@ public readonly record struct ShadowShape
ShadowCollisionType.Sphere,
radius,
0f,
Vector3.Zero);
Vector3.Zero,
new Vector3(-radius),
new Vector3(radius));
}

View file

@ -94,21 +94,25 @@ public static class ShadowShapeBuilder
/// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.</param>
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
/// bounding sphere — retail's <c>CGfxObj::physics_sphere</c>, which is
/// literally <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0. Supplies
/// BOTH the emitted <see cref="ShadowShape.Radius"/> and its
/// <see cref="ShadowShape.BoundsCenter"/>, from one call, so the sphere's
/// size can never be carried while its position is dropped. Null (or a
/// null result) falls back to the loose-but-safe 2 m placeholder at the
/// part origin — a fixture-only configuration; production always supplies
/// it (<c>LiveEntityCollisionBuilder</c>).</param>
/// bounding sphere AND its authored vertex-array box — retail's
/// <c>CGfxObj::physics_sphere</c> (<c>BSPTREE::GetSphere(physics_bsp)</c>
/// @0x005397e0) and <c>CGfxObj::gfx_bound_box</c>
/// (<c>CPhysicsPart::GetBoundingBox</c> @0x0050d600), as ONE
/// <see cref="ShadowPartGeometry"/>. Supplies the emitted
/// <see cref="ShadowShape.Radius"/>, <see cref="ShadowShape.BoundsCenter"/>
/// and <see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c> from one call,
/// so no part of the flood geometry can be carried while another is
/// dropped (AP-156, then #334). Null (or a null result) falls back to the
/// loose-but-safe 2 m placeholder at the part origin — a fixture-only
/// configuration; production always supplies it
/// (<c>LiveEntityCollisionBuilder</c>).</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null,
IReadOnlyList<uint>? effectivePartGfxObjIds = null,
Func<uint, FlatCollisionSphere?>? physicsBspBounds = null)
Func<uint, ShadowPartGeometry?>? physicsBspBounds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
@ -210,17 +214,27 @@ public static class ShadowShapeBuilder
// supplies both so one cannot be taken without the other.
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
// on the part origin because nothing better is known.
// ShadowShape.Bsp scales radius and centre together.
FlatCollisionSphere bounds =
// ShadowShape.Bsp scales radius, centre and box together.
//
// #334: the SAME resolver also supplies the authored vertex-array
// box. Retail's outdoor cell membership
// (CLandCell::add_all_outside_cells @0x00533360, reached from
// find_bbox_cell_list @0x00510fc0) divides that box — never the
// sphere — by square_length to build its cell rectangle, so a
// resolver that answered only the sphere would leave that walk
// with nothing to walk.
ShadowPartGeometry geometry =
physicsBspBounds?.Invoke(gfxId)
?? new FlatCollisionSphere(Vector3.Zero, 2f);
?? ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, 2f),
null);
result.Add(ShadowShape.Bsp(
gfxObjId: gfxId,
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
localRotation: partFrame.Orientation,
scale: entScale,
localBounds: bounds));
localGeometry: geometry));
}
return result;
@ -308,12 +322,21 @@ public static class ShadowShapeBuilder
phys.BoundingSphere?.Origin ?? Vector3.Zero,
phys.BoundingSphere?.Radius ?? 1f);
// #334: the same cached record carries the authored vertex-array
// box (CGfxObj::gfx_bound_box), which retail's outdoor extent walk
// — CLandCell::add_all_outside_cells @0x00533360 — divides by
// square_length. Landblock-baked part arrays are exactly the
// population whose extent exceeds one 24 m land cell, so the
// sphere alone cannot describe their membership.
ShadowPartGeometry geometry =
ShadowPartGeometry.Create(localBounds, phys.VisualBounds);
shapes.Add(ShadowShape.Bsp(
gfxObjId: meshRef.GfxObjId,
localPosition: pPos,
localRotation: pRot,
scale: partScale,
localBounds: localBounds));
localGeometry: geometry));
}
return shapes;

View file

@ -443,8 +443,32 @@ public sealed class CollisionInfo
/// </summary>
internal int ContactPlaneWriteCount { get; private set; }
public void SetContactPlane(Plane plane, uint cellId, bool isWater = false)
public void SetContactPlane(
Plane plane,
uint cellId,
bool isWater = false,
// #337 [support] attribution — recorded on PhysicsDiagnostics, not on
// this object; see the comment in the body for why. Compiler-supplied
// literals: no call site passes these explicitly and none needs to.
[System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "",
[System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0)
{
// #337 attribution (2026-08-06 — TEMPORARY, strip with the probe
// family). Recorded ABOVE the no-op guard on purpose: the meaning is
// "the last site that ASSERTED this plane", not "the site that first
// differed from the previous value" — a sweep that re-derives the
// identical plane it was seeded with has still told you where the
// plane comes from, and that is the fact the capture needs.
//
// It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's
// stored members are compared member-for-member by the flat/graph
// differential referee and by the scratch-reset poison test; a
// diagnostic field there is state those oracles must then be taught to
// ignore, which is how a referee stops refereeing. The latch is
// [ThreadStatic] and is armed once per resolve — see
// PhysicsDiagnostics.BeginContactPlaneAttribution.
PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine);
// A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes
// issue #96 (per-tick CP-write blowup) without removing the
// PhysicsEngine.cs L622 seed that step_up depends on. When the
@ -3695,9 +3719,41 @@ public sealed class Transition
/// </para>
///
/// <para>
/// The per-object distance pre-check below is the analog of the part
/// sorting-sphere early-outs inside retail's
/// <c>CPhysicsObj::FindObjCollisions</c> — response-neutral, pure perf.
/// <b>There is no per-object distance pre-check here, because retail has
/// none.</b> 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 <c>CPhysicsObj::FindObjCollisions</c> —
/// 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 <c>9e847e2f-777c-4bd9-886c-22256bb87f32</c>):
/// <list type="bullet">
/// <item><c>CObjCell::find_obj_collisions</c> @0x0052b750 walks
/// <c>shadow_object_list</c> and calls <c>CPhysicsObj::FindObjCollisions</c>
/// (@0x0050f050, <c>0x0052b78b call</c>) UNCONDITIONALLY; its only
/// early-out is <c>insert_type == INITIAL_PLACEMENT_INSERT</c>
/// (<c>0x0052b759 cmp dword [ebx+0x174],2</c>).</item>
/// <item><c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 contains no
/// float compare at all. Its BSP branch reaches
/// <c>CPartArray::FindObjCollisions</c> (<c>0x0050f18d call 0x518180</c>)
/// directly; the cylsphere and sphere loops are bounded only by
/// <c>GetNumCylsphere</c>/<c>GetNumSphere</c> counts and call the real
/// <c>CCylSphere::intersects_sphere</c> / <c>CSphere::intersects_sphere</c>
/// per primitive — tests, not pre-filters. There are no "sorting-sphere
/// early-outs" in it.</item>
/// <item><c>CPartArray::FindObjCollisions</c> @0x00518180 is a bare
/// do/while over <c>parts[i]</c>, and
/// <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 does two null
/// checks (<c>gfxobj</c>, then <c>gfxobj-&gt;physics_bsp</c> at
/// <c>[ecx+0x78]</c>), <c>SPHEREPATH::cache_localspace_sphere</c>, and
/// <c>CGfxObj::find_obj_collisions</c> @0x00534700. Neither contains a
/// compare or any float math.</item>
/// </list>
/// 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
/// <c>docs/research/2026-08-06-337-neftet-wedge-mechanism.md</c>.
/// </para>
/// </summary>
private TransitionState FindObjCollisionsInCell(PhysicsEngine engine, uint cellId)
@ -3705,12 +3761,30 @@ public sealed class Transition
if (engine.DataCache is null) return TransitionState.OK;
var objsInCell = engine.ShadowObjects.GetObjectsInCell(cellId);
if (objsInCell.Count == 0) return TransitionState.OK;
var sp = SpherePath;
var oi = ObjectInfo;
var ci = CollisionInfo;
// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with
// the physics-probe family). Filtered to the player mover so NPC /
// remote dead-reckoning resolves do not pollute the capture, matching
// PhysicsResolveCapture's filter. The zero-entry query below is
// reported EXPLICITLY: "this cell yielded nothing" is outcome (b) —
// a registration gap — and must appear as data, never as silence.
bool reachProbe = PhysicsDiagnostics.ProbeReachEnabled && oi.IsPlayer;
if (objsInCell.Count == 0)
{
if (reachProbe)
PhysicsDiagnostics.LogReachQuery(
oi.SelfEntityId, cellId, sp.StepDown,
inCell: 0, exempt: 0, reached: 0, rejectedReach: 0,
noShape: 0, tested: 0, blocked: 0,
currPos: sp.GlobalCurrCenter[0].Origin);
return TransitionState.OK;
}
// #42 diagnostic (2026-05-05): identify which static object causes
// the airborne first-frame ~1m push.
bool airborneDiag = !oi.Contact
@ -3730,8 +3804,39 @@ public sealed class Transition
// (DoStepUp → TransitionalInsert → this) must not observe
// registry mutations through the same reference mid-iteration.
using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell);
// #334 probe tallies — see LogReachQuery for what each one decides.
// `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)
{
// #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the
// physics-probe family). Emitted here, at the TOP of the candidate
// loop, so it covers every object the mover comes near regardless
// of what the exemptions and the reach filter later do with it —
// an object whose collision geometry is absent or displaced must
// be reported even when nothing ever tests it. Once per GfxObj per
// process: the line describes an ASSET, not a moment.
if (obj.CollisionType == ShadowCollisionType.BSP
&& PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId))
{
PhysicsDiagnostics.LogGeometryFromAssets(
gfxObjId: obj.GfxObjId,
entityId: obj.EntityId,
flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId),
visual: engine.DataCache.GetVisualBounds(obj.GfxObjId),
entityWorldPosition: obj.Position,
entityScale: obj.Scale,
registeredRadius: obj.Radius);
}
// Self-skip — fix #42 (2026-05-05). Mirrors retail
// CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt
// 308931: `physobj != arg2->object_info.object` rejects the
@ -3745,24 +3850,36 @@ public sealed class Transition
// horizontal push (validated by [SWEEP-OBJ] traces with
// gfxObj=0x02000001 at exactly the entity's own position).
if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId)
{
if (reachProbe)
{
rExempt++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"exempt-self", sphereRadius, movement.Length(), currPos);
}
continue;
}
// OBJECTINFO::missile_ignore 0x0050CEB0 is an all-shapes
// exemption. Retail computes it before the BSP-vs-cyl/sphere
// dispatch; a true result reaches neither branch.
if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags))
{
if (reachProbe)
{
rExempt++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"exempt-missile", sphereRadius, movement.Length(), currPos);
}
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;
if (distToCurr > maxReach)
continue;
// 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++;
// Commit C 2026-04-29 — retail exemption block at the top of
// CPhysicsObj::FindObjCollisions
@ -3773,7 +3890,15 @@ public sealed class Transition
// landblock entries register with State=0 and Flags=None,
// so this is a cheap fall-through for them.
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
{
if (reachProbe)
{
rExempt++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"exempt-rule", sphereRadius, movement.Length(), currPos);
}
continue;
}
// Retail CPhysicsObj::FindObjCollisions ethereal branch
// (pc:276795-276806 / 0x0050f0a2-0x0050f0c9). A target counts as
@ -3799,7 +3924,16 @@ public sealed class Transition
bool etherealForTest = (obj.State & 0x4u) != 0
|| (ObjectInfo.Ethereal && (obj.State & 0x1u) == 0);
if (etherealForTest && sp.StepDown)
continue; // retail pc:276799 — ethereal target not tested in step-down
{
// retail pc:276799 — ethereal target not tested in step-down
if (reachProbe)
{
rExempt++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"exempt-ethereal-stepdown", sphereRadius, movement.Length(), currPos);
}
continue;
}
sp.ObstructionEthereal = etherealForTest;
// L.2a slice 3 (2026-05-12): snapshot collision-normal state so
@ -3848,6 +3982,15 @@ public sealed class Transition
// clear (pc:276989) fires after shape tests; we clear early here to
// leave the flag clean for the next iteration.
sp.ObstructionEthereal = false;
// #334 outcome (c): the entry IS a candidate, the reach
// filter DID admit it, and it still contributes nothing
// because no usable physics BSP resolved for its GfxObj.
if (reachProbe)
{
rNoShape++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"no-shape", sphereRadius, movement.Length(), currPos);
}
continue;
}
@ -3915,6 +4058,12 @@ public sealed class Transition
Console.WriteLine(System.FormattableString.Invariant(
$"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
}
if (reachProbe)
{
rExempt++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"bsp-only-skip", sphereRadius, movement.Length(), currPos);
}
continue;
}
@ -3958,6 +4107,12 @@ public sealed class Transition
Console.WriteLine(System.FormattableString.Invariant(
$"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
}
if (reachProbe)
{
rExempt++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
"bsp-only-skip", sphereRadius, movement.Length(), currPos);
}
continue;
}
@ -4121,6 +4276,24 @@ public sealed class Transition
// per-object test body before the outer loop continues.
sp.ObstructionEthereal = false;
// #334: this candidate actually reached a shape test. `result` is
// post-Layer-2, i.e. what the query will really act on.
if (reachProbe)
{
rTested++;
if (result != TransitionState.OK) rBlocked++;
ProbeReachCandidate(engine, oi, sp, cellId, in obj,
result switch
{
TransitionState.OK => "tested-ok",
TransitionState.Collided => "tested-collided",
TransitionState.Adjusted => "tested-adjusted",
TransitionState.Slid => "tested-slid",
_ => "tested-invalid",
},
sphereRadius, movement.Length(), currPos);
}
if (result != TransitionState.OK)
{
if (airborneDiag)
@ -4133,13 +4306,108 @@ public sealed class Transition
$"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " +
$"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})");
}
// #334 (TEMPORARY): the early exit is a real query outcome, so
// it must be summarised too — otherwise the summary would
// under-report exactly the queries where something DID block,
// and "blocked" is the control that proves the probe can see a
// working collision as well as a missing one.
if (reachProbe)
PhysicsDiagnostics.LogReachQuery(
oi.SelfEntityId, cellId, sp.StepDown,
nearbyObjs.Entries.Length, rExempt, rReached,
rRejected, rNoShape, rTested, rBlocked, currPos);
return result;
}
}
if (reachProbe)
PhysicsDiagnostics.LogReachQuery(
oi.SelfEntityId, cellId, sp.StepDown,
nearbyObjs.Entries.Length, rExempt, rReached,
rRejected, rNoShape, rTested, rBlocked, currPos);
return TransitionState.OK;
}
/// <summary>
/// #334 candidate-disposition probe helper (2026-08-06 — TEMPORARY, strip
/// with the physics-probe family). Static, and takes everything by
/// parameter, so it introduces no closure display class into
/// <see cref="FindObjCollisionsInCell"/> — Slice I1's 0 B/resolve budget
/// must hold with the probe compiled in and switched off.
///
/// <para>
/// The target's physics-BSP ROOT sphere is resolved through the SAME
/// production accessor registration used
/// (<c>GetFlatGfxObj(id).PhysicsBsp</c>'s root node, per
/// <c>LiveEntityCollisionBuilder</c> and <c>ShadowShapeBuilder</c>), so the
/// probe cannot report geometry that differs from what the registry
/// actually emitted. AP-156's lesson was exactly that: one resolver.
/// </para>
/// </summary>
private static void ProbeReachCandidate(
PhysicsEngine engine,
ObjectInfo oi,
SpherePath sp,
uint cellId,
in ShadowEntry obj,
string disposition,
float sphereRadius,
float movementLen,
Vector3 currPos)
{
bool xyOnly = obj.CollisionType == ShadowCollisionType.Cylinder;
Vector3 dOrigin = currPos - obj.Position;
float distOrigin = xyOnly
? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y)
: dOrigin.Length();
// 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)
{
var flatBsp = engine.DataCache?.GetFlatGfxObj(obj.GfxObjId)?.PhysicsBsp;
if (flatBsp is { RootIndex: >= 0 })
{
bspCentreOffset = Vector3.Transform(
flatBsp.Nodes[flatBsp.RootIndex].BoundingSphere.Origin * obj.Scale,
obj.Rotation);
}
}
Vector3 dCentre = currPos - (obj.Position + bspCentreOffset);
float distCentre = xyOnly
? MathF.Sqrt(dCentre.X * dCentre.X + dCentre.Y * dCentre.Y)
: dCentre.Length();
float budget = sphereRadius + obj.Radius + movementLen + 2f;
PhysicsDiagnostics.LogReachCandidate(
moverId: oi.SelfEntityId,
entityId: obj.EntityId,
gfxObjId: obj.GfxObjId,
cellId: cellId,
shape: obj.CollisionType,
disposition: disposition,
stepDown: sp.StepDown,
distOrigin: distOrigin,
distCenter: distCentre,
objRadius: obj.Radius,
sphereRadius: sphereRadius,
movementLen: movementLen,
budget: budget,
centerBudget: budget - 2f,
objPos: obj.Position,
bspCentreOffset: bspCentreOffset,
currPos: currPos);
}
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel —
/// <c>CSortCell::find_collisions</c> (Ghidra 0x005340a0): an outdoor

View file

@ -783,4 +783,52 @@ public static class RenderingDiagnostics
/// </summary>
public static string? FrameHistoryPath { get; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
// ── #337 collision-mesh wireframe (2026-08-06 — TEMPORARY) ──────────────
//
// The F2 collision overlay already existed, but for a BSP object it drew a
// proxy cylinder sized from the REGISTERED BROADPHASE RADIUS. That shows
// where the collision system thinks the object roughly is; it cannot show
// where the collision SURFACES are, which is the only thing that answers
// "is the collision geometry where the visual geometry is". The knobs
// below turn F2 into the real answer: the actual physics-BSP polygon
// edges, in world space, next to the same object's visual mesh box.
//
// Off by default, so F2 keeps its old cheap behaviour for anyone who wants
// it and this costs nothing until asked for.
/// <summary>
/// When true, the F2 collision overlay draws each nearby object's REAL
/// physics-BSP polygon edges (cyan) and, beside them, the same object's
/// visual mesh bounding box (magenta), plus the terrain triangle under the
/// player (yellow). Any separation between the cyan surfaces and the
/// object you can see is the "collision is not where the visual is"
/// defect, read directly off the screen instead of inferred from a log.
/// Initial state from <c>ACDREAM_WIRE_MESH=1</c>.
/// TEMPORARY — strip with the #337 probe family.
/// </summary>
public static bool CollisionMeshWireframeEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1";
/// <summary>
/// Radius in metres around the player within which
/// <see cref="CollisionMeshWireframeEnabled"/> resolves polygon geometry.
/// A whole landblock of rock is far more geometry than a line list wants;
/// 30 m covers everything you can wedge against. Override with
/// <c>ACDREAM_WIRE_RADIUS=&lt;metres&gt;</c>.
/// </summary>
public static float CollisionMeshWireframeRadius { get; set; } =
ParsePositiveFloat(
Environment.GetEnvironmentVariable("ACDREAM_WIRE_RADIUS"),
fallback: 30f);
private static float ParsePositiveFloat(string? raw, float fallback)
=> float.TryParse(
raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float value)
&& value > 0f
? value
: fallback;
}

View file

@ -412,8 +412,10 @@ public sealed class LiveEntityCollisionBuilderTests
/// radius). A fixture pinned at <c>Vector3.Zero</c> cannot observe the
/// centre at all — which is how AP-156's discarded origin stayed green.
/// </summary>
private static FlatCollisionSphere? Bsp(float radius, float centerZ = 1.25f)
=> new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius);
private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f)
=> ShadowPartGeometry.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius),
null);
private static LiveEntityDefaultPoseResolver PoseResolver() => new(
_ => null,

View file

@ -143,8 +143,10 @@ public sealed class PvpBitfieldSurvivesAppearanceRebuildTests
setup.Parts.Add(0x0100AB01u);
var builder = new LiveEntityCollisionBuilder(
id => id == 0x0100AB01u
? new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f)
: null,
? ShadowPartGeometry.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f),
null)
: (ShadowPartGeometry?)null,
new LiveEntityDefaultPoseResolver(
_ => null,
new NullAnimationLoader(),

View file

@ -339,7 +339,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
setup,
EntScale,
id => Bounds(id) is not null,
physicsBspBounds: Bounds);
physicsBspBounds: id => Bounds(id) is { } sphere
? ShadowPartGeometry.Create(sphere, null)
: (ShadowPartGeometry?)null);
// ShadowObjectRegistry.BuildFloodSpheres' composition, at an
// entity placed at the world origin with identity rotation.

View file

@ -0,0 +1,142 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Content.Tests;
/// <summary>
/// #334, replayed against the installed DATs from the user's own live
/// evidence (<c>334-neftet-probe.log</c>, 8,401 lines, 2026-08-06).
///
/// <para>
/// Standing inside the Neftet rock formation the broadphase reported
/// <c>inCell=2 exempt=2 reached=0</c> — the formation was not in the player's
/// cell at all. The one object that DID block, GfxObj <c>0x010046D8</c>
/// (root bounding sphere radius 69.471 m, bounds centre 34.977 m off the part
/// origin), was measured PRESENT in cells <c>0x8764000A</c> and
/// <c>0x87640012</c> and ABSENT from <c>0x87640011</c>, <c>0x87640019</c> and
/// <c>0x87630018</c>. Those five observations have exactly one explanation:
/// a 3×3 land-cell neighbourhood centred on <c>0x87640013</c> — the cell
/// under the object's own position — which is what
/// <c>CellTransit.AddAllOutsideCells</c> produces for ANY sphere, because its
/// <c>minRad = radius</c> / <c>maxRad = 24 - radius</c> boundary tests are
/// unconditionally true above 12 m and it only ever adds the eight
/// neighbours.
/// </para>
///
/// <para>
/// This test asserts the observed reality rather than a constant: the two
/// cells the probe measured EMPTY must be occupied, and the two it measured
/// POPULATED must stay occupied. It fails at <c>f0588725</c> (the sphere route
/// cannot reach cellY 0 from a centre at cellY 2) and passes with the box
/// route. The expected rectangle was derived from the object's own
/// <c>CGfxObj::gfx_bound_box</c> read out of <c>client_portal.dat</c>, not
/// from running the code under test: the box is 96 m × 96 m about a part
/// origin at block-local (63.78, 56.29), i.e. cell (2,2), so it spans cell
/// columns 0..4 on both axes.
/// </para>
/// </summary>
public sealed class Issue334NeftetFormationCellMembershipTests
{
private const uint NeftetLandblock = 0x87640000u;
private const uint NeftetLandblockInfo = 0x8764FFFEu;
private const uint FormationGfxObj = 0x010046D8u;
// The four cells named in the probe log, by their measured disposition.
private const uint MeasuredPresentA = 0x8764000Au; // lcoord (1081, 801)
private const uint MeasuredPresentB = 0x87640012u; // lcoord (1082, 801)
private const uint MeasuredAbsentA = 0x87640011u; // lcoord (1082, 800)
private const uint MeasuredAbsentB = 0x87640019u; // lcoord (1083, 800)
[Fact]
public void NeftetFormation_RegistersInTheCellsTheProbeMeasuredEmpty()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
Assert.True(
dats.Cell.TryGet<LandBlockInfo>(NeftetLandblockInfo, out LandBlockInfo? info)
&& info is not null,
"Neftet landblock info 0x8764FFFE is absent from the installed cell dat.");
// The probe's entity 0xC8764000 is stab index 0 of this landblock
// (LandblockStaticEntityIdAllocator's 0xCXXYYIII packing).
Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj);
Assert.True(
dats.Portal.TryGet<GfxObj>(FormationGfxObj, out GfxObj? gfx) && gfx is not null,
"GfxObj 0x010046D8 is absent from the installed portal dat.");
// Control: the fixture must be non-degenerate on the axis under test.
// A box that fits inside its own bounding sphere makes the box route
// and the sphere route agree, and proves nothing.
var cache = new PhysicsDataCache();
cache.CacheGfxObj(FormationGfxObj, gfx!);
GfxObjPhysics? phys = cache.GetGfxObj(FormationGfxObj);
Assert.NotNull(phys);
Assert.NotNull(phys!.VisualBounds);
FlatGfxObjVisualBounds box = phys.VisualBounds!.Value;
float extentX = box.Max.X - box.Min.X;
float extentY = box.Max.Y - box.Min.Y;
float rootRadius = phys.BoundingSphere!.Radius;
Assert.True(
extentX > rootRadius && extentY > rootRadius,
$"Fixture is degenerate: extent ({extentX:F2}, {extentY:F2}) does not " +
$"exceed the root sphere radius {rootRadius:F3}.");
Assert.True(
extentX > 48f && extentY > 48f,
$"Fixture cannot reach two cells away: extent ({extentX:F2}, {extentY:F2}).");
IReadOnlyList<ShadowShape> shapes =
ShadowShapeBuilder.FromLandblockBspParts(
new[] { new MeshRef(FormationGfxObj, Matrix4x4.Identity) },
isBuildingShell: false,
cache.GetGfxObj);
ShadowShape only = Assert.Single(shapes);
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
var registry = new ShadowObjectRegistry { DataCache = cache };
const uint ownerId = 0xC8764000u;
registry.RegisterMultiPart(
ownerId,
formation.Frame.Origin,
formation.Frame.Orientation,
shapes,
0u,
EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: NeftetLandblock,
seedCellId: 0u,
isStatic: true);
var held = new List<uint>();
for (uint index = 1u; index <= 64u; index++)
{
uint cellId = NeftetLandblock | index;
if (registry.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId))
held.Add(cellId);
}
// The measured-populated pair must stay populated.
Assert.Contains(MeasuredPresentA, held);
Assert.Contains(MeasuredPresentB, held);
// The measured-EMPTY pair is the #334 fact.
Assert.Contains(MeasuredAbsentA, held);
Assert.Contains(MeasuredAbsentB, held);
// And the rectangle is the 5×5 the 96 m box spans about cell (2,2),
// clipped to this landblock's own 8×8 grid (the two columns below 0
// land in the neighbour blocks 0x8763 / 0x8664 and are counted there).
Assert.Equal(25, held.Count);
}
}

View file

@ -926,7 +926,9 @@ public class DoorBugTrajectoryReplayTests
s.LocalPosition,
s.LocalRotation,
s.Scale,
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale)));
ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale),
null)));
}
else
{
@ -1113,7 +1115,7 @@ public class DoorBugTrajectoryReplayTests
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius));
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, BspRadius), null));
var cylShape = ShadowShape.Cylinder(
gfxObjId: 0u,

View file

@ -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;
/// <summary>
/// #333 / #337 regression — the query-site broadphase reach filter.
///
/// <para>
/// <c>Transition.FindObjCollisionsInCell</c> used to discard a shadow candidate
/// when <c>|currPos - obj.Position| &gt; sphereRadius + obj.Radius +
/// movement.Length() + 2f</c>. <c>obj.Position</c> is the part ORIGIN while
/// <c>obj.Radius</c> 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 <c>BSPQuery</c> 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
/// <c>docs/research/2026-08-06-337-neftet-wedge-mechanism.md</c>.
/// </para>
///
/// <para>
/// Retail has no such filter. Disassembled from the PDB-paired v11.4186
/// binary: <c>CObjCell::find_obj_collisions</c> @0x0052b750 calls
/// <c>CPhysicsObj::FindObjCollisions</c> unconditionally,
/// <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 has no float compare at
/// all, <c>CPartArray::FindObjCollisions</c> @0x00518180 is a bare loop over
/// parts, and <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 is two null
/// checks plus <c>CGfxObj::find_obj_collisions</c> @0x00534700.
/// </para>
///
/// <para>
/// This fixture is deliberately DAT-free so the gate runs everywhere, and it
/// drives the PRODUCTION path end-to-end
/// (<c>PhysicsEngine.ResolveWithTransition</c> →
/// <c>Transition.FindObjCollisionsInCell</c> → <c>CollisionTraversal</c>)
/// rather than re-computing a predicate in the test. Sabotage: restore the
/// <c>maxReach</c> pre-check and <see cref="OffCentreBspFloorStopsAFallingMover"/>
/// fails while <see cref="CentredBspFloorStopsAFallingMover"/> keeps passing —
/// the pair discriminates "the filter is gone" from "the fixture cannot fall".
/// </para>
/// </summary>
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;
/// <summary>Part origin. The filter measured to THIS point.</summary>
private static readonly Vector3 PartOrigin = new(12f, 12f, 0f);
/// <summary>
/// Root bounding-sphere radius the registry publishes for this object,
/// and the radius of the single BSP leaf node.
/// </summary>
private const float RootSphereRadius = 6f;
/// <summary>
/// #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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
[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.");
}
/// <summary>
/// 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 <see cref="OffCentreBspFloorStopsAFallingMover"/> for the wrong
/// reason.
/// </summary>
[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}.");
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// One static BSP owner at <see cref="PartOrigin"/> carrying a single
/// horizontal floor polygon (8 × 8 m, normal +Z) at
/// <paramref name="slabLocalZ"/> 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.
/// </summary>
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<CellSurface>(),
portals: System.Array.Empty<PortalPlane>(),
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<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon> { [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;
}
}

View file

@ -0,0 +1,412 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #334: a physics-BSP object's outdoor cell membership is the FILLED
/// RECTANGLE of land cells its authored bounding box spans, not a fixed 3×3
/// neighbourhood.
///
/// <para>
/// Retail chain, disassembled from the PDB-paired 2013-09-06 binary:
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230
/// (<c>0x00515285 test dword [esi+0xa8],0x10000</c>) →
/// <c>find_bbox_cell_list</c> @0x00510fc0 →
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 →
/// <c>[vtbl+0x7c]</c> → <c>CLandCell::find_transit_cells</c> @0x00533840 →
/// <c>add_all_outside_cells</c> @0x00533360 → <c>add_cell_block</c>
/// @0x005331d0.
/// </para>
///
/// <para>
/// EVERY fixture here has an XY extent that EXCEEDS its own bounding-sphere
/// radius. That is the axis under test: a box that fits inside its sphere
/// makes the new path and the old 3×3 agree, and proves nothing. The sphere
/// radius is deliberately kept at 1 m so no assertion below can be satisfied
/// by the sphere route — retail's outdoor sphere reach is hard-capped at ±1
/// cell for ANY radius (<c>check_add_cell_boundary</c> compares against
/// <c>radius</c> and <c>24 - radius</c>, both unconditionally true above
/// 12 m, and only ever adds the eight neighbours).
/// </para>
/// </summary>
public sealed class Issue334BspBoxCellMembershipTests
{
// Landblock (0xA9, 0xB4). Global lcoord origin = (0xA9*8, 0xB4*8).
private const uint LbId = 0xA9B40000u;
private const int GxBase = 0xA9 * 8; // 1352
private const int GyBase = 0xB4 * 8; // 1440
/// <summary>Full outdoor cell id from a GLOBAL lcoord, hand-derived from
/// retail's <c>add_cell_block</c> packing at <c>0x0053320a</c>-<c>0x0053322e</c>:
/// <c>(((x&gt;&gt;3)&lt;&lt;8) | (y&gt;&gt;3)) &lt;&lt; 16 | ((x&amp;7)*8 + (y&amp;7) + 1)</c>.
/// Written out here rather than calling LandDefs so the expectation does
/// not re-encode the code under test.</summary>
private static uint Cell(int gx, int gy)
=> (uint)(((((gx >> 3) << 8) | (gy >> 3)) << 16) | ((gx & 7) * 8 + (gy & 7) + 1));
private static ShadowShape BspPart(
Vector3 boxMin,
Vector3 boxMax,
float sphereRadius = 1f,
Vector3 sphereCentre = default,
Vector3 localPosition = default,
Quaternion localRotation = default)
=> ShadowShape.Bsp(
gfxObjId: 0x010046D8u,
localPosition: localPosition,
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(sphereCentre, sphereRadius),
new FlatGfxObjVisualBounds(
boxMin,
boxMax,
(boxMin + boxMax) * 0.5f,
((boxMax - boxMin) * 0.5f).Length(),
(boxMax - boxMin) * 0.5f)));
/// <summary>The sphere-only configuration the port replaced: box collapses
/// to the sphere's own AABB. Used as the in-test control that the fixture
/// is non-degenerate.</summary>
private static ShadowShape SphereOnlyPart(
float sphereRadius,
Vector3 sphereCentre = default,
Vector3 localPosition = default)
=> ShadowShape.Bsp(
gfxObjId: 0x010046D8u,
localPosition: localPosition,
localRotation: Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(sphereCentre, sphereRadius),
null));
private static List<uint> Rectangle(
Vector3 entityWorldPos,
uint seedCellId,
params ShadowShape[] shapes)
{
var boxes = shapes
.Select(s => ShadowPartBox.FromShape(s, entityWorldPos, Quaternion.Identity))
.ToList();
var candidates = new CellArray();
CellTransit.AddAllOutsideCellsFromParts(
boxes, seedCellId, Vector3.Zero, candidates);
return candidates.OrderedIds.ToList();
}
// ── T1 ────────────────────────────────────────────────────────────────
/// <summary>
/// A 100 m × 100 m box on a 1 m sphere spans five land cells per axis.
/// Sabotage: drop the box and flood from the sphere
/// (<see cref="SphereOnlyPart"/>) → one cell. The 5-per-axis span is
/// unreachable from ANY sphere, of any radius, through the 3×3 path.
/// </summary>
[Fact]
public void T1_HundredMetreBox_SpansFiveCellsPerAxis()
{
// Entity centred on cell (1,1): world (36, 36). Box ±50 m → world
// -14..86 per axis → floor(-14/24) = -1 .. floor(86/24) = 3, i.e.
// block-local cell columns -1..3, five per axis.
var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f));
List<uint> cells = Rectangle(new Vector3(36f, 36f, 0f), LbId | 10u, shape);
var expected = new List<uint>();
for (int x = GxBase - 1; x <= GxBase + 3; x++)
for (int y = GyBase - 1; y <= GyBase + 3; y++)
expected.Add(Cell(x, y));
Assert.Equal(25, cells.Count);
Assert.Equal(expected.OrderBy(v => v), cells.OrderBy(v => v));
// Control: the same part described only by its 1 m sphere collapses.
List<uint> sphereOnly = Rectangle(
new Vector3(36f, 36f, 0f), LbId | 10u, SphereOnlyPart(1f));
Assert.Single(sphereOnly);
Assert.Equal(Cell(GxBase + 1, GyBase + 1), sphereOnly[0]);
}
// ── T2 ────────────────────────────────────────────────────────────────
/// <summary>
/// The rectangle is FILLED and unioned ACROSS PARTS, not per part. Retail
/// combines the four DELTA accumulators over every part and calls
/// <c>add_cell_block</c> ONCE (<c>0x00533614</c>), so an L-shaped object
/// registers in the cells that close its L — cells its geometry never
/// enters.
///
/// <para>
/// The fixture is an L on purpose: one arm along +X, one along +Y. A
/// DIAGONAL fixture cannot detect the per-part sabotage, because retail
/// seeds the accumulators to ZERO (<c>0x00533390</c>), so each part's own
/// rectangle already spans from the base cell to that part — and for a
/// diagonal pair the two per-part rectangles union back to the same square.
/// Sabotage: emit one rectangle per part → the corner (3,3) disappears.
/// </para>
/// </summary>
[Fact]
public void T2_LShapedPartArray_ClaimsTheCornerThatClosesTheL()
{
var box = (Min: new Vector3(-5f, -5f, -2f), Max: new Vector3(5f, 5f, 2f));
var anchor = BspPart(box.Min, box.Max);
var eastArm = BspPart(box.Min, box.Max, localPosition: new Vector3(48f, 0f, 0f));
var northArm = BspPart(box.Min, box.Max, localPosition: new Vector3(0f, 48f, 0f));
var entity = new Vector3(36f, 36f, 0f);
List<uint> cells = Rectangle(entity, LbId | 10u, anchor, eastArm, northArm);
uint corner = Cell(GxBase + 3, GyBase + 3);
Assert.Contains(corner, cells);
Assert.Equal(9, cells.Count);
// Control: the corner is not reachable from any part's own rectangle,
// so the containment above cannot be satisfied by a per-part union.
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor));
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, eastArm));
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, northArm));
}
// ── T3 ────────────────────────────────────────────────────────────────
/// <summary>
/// The rectangle crosses landblock boundaries freely: <c>add_cell_block</c>
/// works in GLOBAL lcoords and re-derives the block prefix per cell
/// (<c>0x0053320a</c>), so cells beyond column 7 carry the NEIGHBOUR
/// landblock's id. Sabotage: clamp the rectangle to the seed landblock →
/// the 0xAAB4 / 0xA9B5 rows vanish.
/// </summary>
[Fact]
public void T3_BoxPastTheBlockEdge_ProducesNeighbourLandblockCellIds()
{
// Entity on cell (7,7): world (180, 180). Box ±30 m → world 150..210
// → cell columns 6..8; column 8 is the neighbour block's column 0.
var shape = BspPart(new Vector3(-30f, -30f, -2f), new Vector3(30f, 30f, 2f));
List<uint> cells = Rectangle(new Vector3(180f, 180f, 0f), LbId | 64u, shape);
Assert.Equal(9, cells.Count);
Assert.Contains(Cell(GxBase + 7, GyBase + 7), cells); // 0xA9B40040
Assert.Contains(Cell(GxBase + 8, GyBase + 7), cells); // 0xAAB4xxxx
Assert.Contains(Cell(GxBase + 7, GyBase + 8), cells); // 0xA9B5xxxx
Assert.Contains(Cell(GxBase + 8, GyBase + 8), cells); // 0xAAB5xxxx
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB40000u);
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xA9B50000u);
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB50000u);
}
// ── T4 ────────────────────────────────────────────────────────────────
/// <summary>
/// Map bounds. <c>add_cell_block</c> rejects any coordinate outside
/// <c>[0, 0x7f8)</c> (<c>0x005331f0</c>-<c>0x00533206</c>). Sabotage: drop
/// the clamp → cells wrap into the far corner of the map or produce id 0.
/// </summary>
[Fact]
public void T4_RectangleAtTheMapCorners_EmitsNothingOutsideTheMap()
{
// SW corner: landblock (0,0), entity on cell (0,0), box ±50 m reaches
// three cells into negative lcoords on both axes.
var box = BspPart(new Vector3(-50f, -50f, -2f), new Vector3(50f, 50f, 2f));
List<uint> sw = Rectangle(new Vector3(12f, 12f, 0f), 0x00000001u, box);
Assert.All(sw, id => Assert.NotEqual(0u, id));
Assert.Equal(9, sw.Count); // x 0..2 × y 0..2 survive
Assert.Contains(0x00000001u, sw);
// NE corner: landblock (254,254) — lcoords 2032..2039, the last legal
// row before 0x7f8 = 2040.
const uint neLb = 0xFEFE0000u;
int neGx = 254 * 8, neGy = 254 * 8;
List<uint> ne = Rectangle(new Vector3(180f, 180f, 0f), neLb | 64u, box);
Assert.All(ne, id => Assert.NotEqual(0u, id));
Assert.Equal(9, ne.Count); // x 2035..2037+ y likewise
Assert.Contains(Cell(neGx + 7, neGy + 7), ne);
Assert.DoesNotContain(Cell(2040 & 0x7FF, 2040 & 0x7FF), ne);
}
// ── T5 ────────────────────────────────────────────────────────────────
/// <summary>
/// <c>BBox::LocalToGlobal</c> @0x005b2120 re-fits through ALL EIGHT
/// corners, so a rotated box grows. Sabotage: transform only
/// <c>min</c> and <c>max</c> → the X overhang of a yawed asymmetric box
/// is lost and its westernmost cell disappears.
/// </summary>
[Fact]
public void T5_RotatedAsymmetricBox_KeepsTheCornerOverhangMinMaxWouldLose()
{
// Asymmetric box: X 0..60, Y 0..4. Yawed 37 degrees about Z the four
// XY corners land at (0,0), (47.92,36.11), (-2.41,3.19), (45.52,39.30);
// the true AABB therefore starts at x = -2.41, which is the corner a
// min/max-only transform (which sees only (0,0) and (45.52,39.30))
// cannot produce.
Quaternion yaw37 = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ, 37f * MathF.PI / 180f);
var shape = BspPart(
new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f),
localRotation: yaw37);
// Entity at world x = 48 → the true box spans 45.59..95.92, crossing
// into cell column 1; the min/max-only box starts at exactly 48.0,
// which is column 2.
List<uint> cells = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, shape);
Assert.Contains(Cell(GxBase + 1, GyBase + 0), cells);
Assert.Contains(Cell(GxBase + 3, GyBase + 2), cells);
// Control: unrotated, the same box starts at exactly x = 48 and never
// reaches column 1 — so the containment above is the rotation's doing.
var unrotated = BspPart(new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f));
List<uint> flat = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, unrotated);
Assert.DoesNotContain(Cell(GxBase + 1, GyBase + 0), flat);
}
// ── T9 ────────────────────────────────────────────────────────────────
/// <summary>
/// <c>floor</c>, not truncation: retail calls <c>floor</c> then
/// <c>_ftol2</c> (<c>0x0053353c</c> / <c>0x00533542</c>). Sabotage:
/// <c>(int)(v / 24f)</c> → for a box overhanging the block's SW corner,
/// <c>-8/24</c> truncates to 0 and the previous landblock's column 7 is
/// silently dropped.
/// </summary>
[Fact]
public void T9_BoxOverhangingTheBlockOrigin_ReachesTheNegativeColumn()
{
var shape = BspPart(new Vector3(-20f, -20f, -2f), new Vector3(20f, 20f, 2f));
// Entity at world (12, 12): the box spans -8..32, whose floor is -1.
List<uint> cells = Rectangle(new Vector3(12f, 12f, 0f), LbId | 1u, shape);
Assert.Contains(Cell(GxBase - 1, GyBase - 1), cells); // 0xA8B3, cell 64
Assert.Contains(Cell(GxBase - 1, GyBase + 0), cells);
Assert.Contains(Cell(GxBase + 0, GyBase - 1), cells);
// -8..32 → floor gives columns -1..1, three per axis.
Assert.Equal(9, cells.Count);
Assert.Contains(Cell(GxBase + 1, GyBase + 1), cells);
}
// ── T8 ────────────────────────────────────────────────────────────────
/// <summary>
/// <c>adjust_to_outside</c> failing (map edge / invalid id) makes retail
/// return before <c>get_landcell</c> and add nothing
/// (<c>0x005333eb</c> select → gid 0 → <c>0x00533417 je</c>). Sabotage:
/// drop the null check → an exception or a bogus rectangle at lcoord 0.
/// </summary>
[Fact]
public void T8_BasePositionOffTheMap_AddsNothingAndDoesNotThrow()
{
var shape = BspPart(new Vector3(-5f, -5f, -2f), new Vector3(5f, 5f, 2f));
var boxes = new List<ShadowPartBox>
{
ShadowPartBox.FromShape(
shape, new Vector3(-100000f, -100000f, 0f), Quaternion.Identity),
};
var candidates = new CellArray();
bool added = CellTransit.AddAllOutsideCellsFromParts(
boxes, 0x00000001u, Vector3.Zero, candidates);
Assert.False(added);
Assert.Empty(candidates.OrderedIds);
}
// ── P2 / T6 ───────────────────────────────────────────────────────────
/// <summary>
/// Non-BSP invariance. A cylinder-only owner must still take retail's
/// cylsphere branch — <c>CObjCell::find_cell_list</c> @0x0052b9f0 — and
/// produce exactly the sphere flood's cell set. Sabotage: route every
/// owner through the box path → the sets diverge (the cylinder's box is
/// its own ±radius extent, which spans a different rectangle).
/// </summary>
[Fact]
public void T6_CylinderOnlyOwner_MatchesTheUntouchedSphereFlood()
{
var cylinder = ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1f,
// r = 12 at the exact centre of cell (1,1) is the configuration in
// which the two routes DISAGREE: check_add_cell_boundary's tests
// are STRICT (pointX > 24-r, pointX < r), so 12 > 12 and 12 < 12
// both fail and the sphere claims exactly one cell — while the
// same extent as a BOX spans 24..48, whose floor is columns 1 AND
// 2. A fixture at any other radius makes the routes agree and
// proves nothing.
radius: 12f,
cylHeight: 24f);
var reg = new ShadowObjectRegistry();
const uint ownerId = 0x334001u;
var worldPos = new Vector3(36f, 36f, 50f);
reg.RegisterMultiPart(
ownerId, worldPos, Quaternion.Identity,
new[] { cylinder }, 0u, EntityCollisionFlags.None,
0f, 0f, LbId);
IReadOnlyList<uint> expected = CellTransit.BuildShadowCellSet(
new PhysicsDataCache(),
LbId | 10u,
new[]
{
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = 12f },
},
1,
isStatic: false);
// Control: the golden must be the SINGLE cell only the sphere route
// produces, so the equality below cannot be satisfied by the box route.
Assert.Equal(new[] { LbId | 10u }, expected);
var actual = new List<uint>();
foreach (uint id in expected)
{
if (reg.GetObjectsInCell(id).Any(e => e.EntityId == ownerId))
actual.Add(id);
}
Assert.NotEmpty(expected);
Assert.Equal(expected.OrderBy(v => v), actual.OrderBy(v => v));
// And nothing outside it: the cylinder claims no cell the sphere
// flood did not.
for (uint index = 1u; index <= 64u; index++)
{
uint cellId = LbId | index;
bool held = reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId);
Assert.Equal(expected.Contains(cellId), held);
}
}
// ── P1 / dispatch ─────────────────────────────────────────────────────
/// <summary>
/// The dispatch itself: a BSP-bearing owner registered through
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> lands in EVERY cell
/// of its box rectangle — not the nine of the sphere neighbourhood. This
/// is the end-to-end #334 fact at the production entry point.
/// </summary>
[Fact]
public void RegisterMultiPart_BspBearingOwner_OccupiesTheFullBoxRectangle()
{
var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f));
var reg = new ShadowObjectRegistry();
const uint ownerId = 0x334002u;
reg.RegisterMultiPart(
ownerId, new Vector3(36f, 36f, 0f), Quaternion.Identity,
new[] { shape }, 0u, EntityCollisionFlags.None,
0f, 0f, LbId, seedCellId: LbId | 10u);
int held = 0;
for (int x = GxBase - 1; x <= GxBase + 3; x++)
for (int y = GyBase - 1; y <= GyBase + 3; y++)
{
uint cellId = Cell(x, y);
Assert.Contains(
reg.GetObjectsInCell(cellId),
e => e.EntityId == ownerId);
held++;
}
Assert.Equal(25, held);
}
}

View file

@ -0,0 +1,946 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Text;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #337 offline replay (TEMPORARY — strip with the physics-probe family).
///
/// <para>
/// Reconstructs, from the installed DAT alone, the exact world placement of
/// every landblock-static collision owner in Neftet landblock 0x8766, and
/// asks the one question <c>[geom]</c> cannot answer: the <c>[geom]</c> probe
/// compares the physics-BSP vertex cloud against the visual mesh box in the
/// object's OWN LOCAL FRAME, so <c>verdict=coincident</c> proves only that the
/// two agree on SHAPE. It says nothing about WHERE the object sits in the
/// world. This test places the geometry in the world and measures.
/// </para>
///
/// <para>
/// Live measurement being replayed (337-support.log / 334-fix-gate.log):
/// the player wedges at (134.314, 55.716, 50.011) in cell 0x8766002B, and
/// owner 0xC8766002 / gfx 0x010046DE was tested 11,014 times there with
/// 0 adjusted, 0 collided, 0 slid — while its two neighbours 0xC8766003 and
/// 0xC8766009 adjusted 767 and 1,135 times against the same mover.
/// </para>
///
/// Dat-data dependent; SKIPs cleanly without ACDREAM_DAT_DIR.
/// </summary>
public sealed class Issue337NeftetRockGeometryInspectionTests
{
private const uint Landblock = 0x8766_0000u;
private const uint GfxObjMask = 0x01000000u;
private const uint SetupMask = 0x02000000u;
private const uint TypeMask = 0xFF000000u;
/// <summary>The wedge point, landblock-local, from the live capture.</summary>
private static readonly Vector3 Wedge = new(134.313934f, 55.716248f, 50.010666f);
private readonly ITestOutputHelper _out;
public Issue337NeftetRockGeometryInspectionTests(ITestOutputHelper o) => _out = o;
private sealed record Placed(
uint EntityId,
uint DatId,
uint GfxObjId,
Vector3 Position,
Quaternion Rotation,
List<Vector3[]> WorldPolygons,
List<Vector3[]> LocalPolygons,
FlatPhysicsBsp Bsp,
Vector3 WorldMin,
Vector3 WorldMax,
int BspNodes,
int BspPolys);
[Fact]
public void DumpNeftetLandblockStaticsAndProbeTheWedgePoint()
{
System.Globalization.CultureInfo.CurrentCulture =
System.Globalization.CultureInfo.InvariantCulture;
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
Assert.NotNull(info);
var sb = new StringBuilder();
sb.AppendLine(Inv(
$"landblock 0x{Landblock:X8}: Objects={info!.Objects.Count} Buildings={info.Buildings.Count}"));
uint lbX = (Landblock >> 24) & 0xFFu;
uint lbY = (Landblock >> 16) & 0xFFu;
uint counter = 0;
var cache = new PhysicsDataCache();
var placed = new List<Placed>();
foreach (var stab in info.Objects)
{
if (!IsSupported(stab.Id)) continue;
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
placed.AddRange(Place(dats, cache, entityId, stab.Id,
stab.Frame.Origin, stab.Frame.Orientation));
}
foreach (var building in info.Buildings)
{
if (!IsSupported(building.ModelId)) continue;
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
placed.AddRange(Place(dats, cache, entityId, building.ModelId,
building.Frame.Origin, building.Frame.Orientation));
}
sb.AppendLine($"placed collision parts: {placed.Count}");
sb.AppendLine();
sb.AppendLine("--- every part: world box, and the wedge point in ITS OWN local frame ---");
foreach (var p in placed.OrderBy(p => p.EntityId).ThenBy(p => p.GfxObjId))
{
Vector3 local = Vector3.Transform(
Wedge - p.Position, Quaternion.Inverse(p.Rotation));
bool inWorldBox = Inside(p.WorldMin, p.WorldMax, Wedge);
sb.AppendLine(Inv(
$" ent=0x{p.EntityId:X8} dat=0x{p.DatId:X8} gfx=0x{p.GfxObjId:X8} " +
$"pos=({p.Position.X:F3},{p.Position.Y:F3},{p.Position.Z:F3}) " +
$"rot=({p.Rotation.W:F4},{p.Rotation.X:F4},{p.Rotation.Y:F4},{p.Rotation.Z:F4}) " +
$"yawDeg={YawDegrees(p.Rotation):F2} " +
$"nodes={p.BspNodes} polys={p.BspPolys} " +
$"worldMin=({p.WorldMin.X:F2},{p.WorldMin.Y:F2},{p.WorldMin.Z:F2}) " +
$"worldMax=({p.WorldMax.X:F2},{p.WorldMax.Y:F2},{p.WorldMax.Z:F2}) " +
$"wedgeLocal=({local.X:F2},{local.Y:F2},{local.Z:F2}) " +
$"wedgeInWorldBox={inWorldBox}"));
}
sb.AppendLine();
sb.AppendLine(Inv(
$"--- vertical column through the wedge XY ({Wedge.X:F3},{Wedge.Y:F3}) ---"));
sb.AppendLine("(every physics polygon whose WORLD XY projection contains that point,");
sb.AppendLine(" with the Z of the polygon's plane at that XY — this is the surface");
sb.AppendLine(" a falling body would land on and the ceiling it would be under)");
var hits = new List<(uint Ent, uint Gfx, float Z, float Nz, int PolyIndex)>();
foreach (var p in placed)
{
for (int i = 0; i < p.WorldPolygons.Count; i++)
{
Vector3[] poly = p.WorldPolygons[i];
if (!XyContains(poly, Wedge.X, Wedge.Y)) continue;
if (!TryPlaneZ(poly, Wedge.X, Wedge.Y, out float z, out float nz)) continue;
hits.Add((p.EntityId, p.GfxObjId, z, nz, i));
}
}
foreach (var h in hits.OrderByDescending(h => h.Z))
{
sb.AppendLine(Inv(
$" z={h.Z,9:F3} normalZ={h.Nz,7:F4} ent=0x{h.Ent:X8} gfx=0x{h.Gfx:X8} poly#{h.PolyIndex}"));
}
if (hits.Count == 0)
sb.AppendLine(" (no physics polygon covers that XY at all)");
sb.AppendLine();
sb.AppendLine(Inv(
$"player sphere centre is at z={Wedge.Z:F3}"));
var above = hits.Where(h => h.Z > Wedge.Z).OrderBy(h => h.Z).ToList();
var below = hits.Where(h => h.Z <= Wedge.Z).OrderByDescending(h => h.Z).ToList();
sb.AppendLine(Inv(
$" nearest surface BELOW: {(below.Count == 0 ? "none" : $"z={below[0].Z:F3} ent=0x{below[0].Ent:X8} gfx=0x{below[0].Gfx:X8} (gap {Wedge.Z - below[0].Z:F3} m)")}"));
sb.AppendLine(Inv(
$" nearest surface ABOVE: {(above.Count == 0 ? "none" : $"z={above[0].Z:F3} ent=0x{above[0].Ent:X8} gfx=0x{above[0].Gfx:X8} (gap {above[0].Z - Wedge.Z:F3} m)")}"));
sb.AppendLine();
sb.AppendLine("--- nearest physics polygon to the wedge point, per owner ---");
foreach (var p in placed.OrderBy(p => p.EntityId))
{
float best = float.MaxValue;
int bestIndex = -1;
for (int i = 0; i < p.WorldPolygons.Count; i++)
{
float d = DistanceToPolygon(p.WorldPolygons[i], Wedge);
if (d < best) { best = d; bestIndex = i; }
}
sb.AppendLine(Inv(
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} nearestPolyDist={best:F3} m (poly#{bestIndex})"));
}
// ------------------------------------------------------------------
// REFEREE. Two independent answers to the SAME question, per owner:
// (a) the production BSP walk, FlatBspQuery.SphereIntersectsPoly —
// the Path-5 static-overlap primitive the resolver actually runs,
// node bounding-sphere early-outs and all;
// (b) brute force over every polygon the tree indexes.
// They must agree. Where (b) says "a polygon is inside the sphere" and
// (a) says "no", the geometry is present and the TRAVERSAL cannot reach
// it — a bounded pocket, which is exactly the reported symptom. Where
// both say no, the mesh genuinely is not there and the owner is
// innocent.
//
// Sample points are taken ON the mesh (vertices + centroids, nudged
// both ways along the polygon normal) so the referee covers the whole
// surface rather than an arbitrary lattice, and any disagreement
// localises to a named polygon.
// ------------------------------------------------------------------
const float SphereRadius = 0.48f; // the live mover's radius
sb.AppendLine();
sb.AppendLine(Inv(
$"--- referee: BSP walk vs brute force, sphere r={SphereRadius:F3} ---"));
foreach (var p in placed.OrderBy(p => p.EntityId))
{
int samples = 0, mismatch = 0, bothHit = 0;
var mmMin = new Vector3(float.PositiveInfinity);
var mmMax = new Vector3(float.NegativeInfinity);
var firstMismatches = new List<string>();
foreach (Vector3 probe in SurfaceProbes(p.LocalPolygons, SphereRadius))
{
samples++;
bool walk = FlatBspQuery.SphereIntersectsPoly(
p.Bsp, probe, SphereRadius, out _, out _);
bool brute = BruteHit(p.LocalPolygons, probe, SphereRadius);
if (walk == brute) { if (walk) bothHit++; continue; }
mismatch++;
Vector3 world = p.Position + Vector3.Transform(probe, p.Rotation);
mmMin = Vector3.Min(mmMin, world);
mmMax = Vector3.Max(mmMax, world);
if (firstMismatches.Count < 8)
{
firstMismatches.Add(Inv(
$" local=({probe.X:F2},{probe.Y:F2},{probe.Z:F2}) " +
$"world=({world.X:F2},{world.Y:F2},{world.Z:F2}) " +
$"walk={walk} brute={brute}"));
}
}
sb.AppendLine(Inv(
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} polys={p.BspPolys} " +
$"samples={samples} bothHit={bothHit} MISMATCH={mismatch}"));
if (mismatch > 0)
{
sb.AppendLine(Inv(
$" mismatch world box=({mmMin.X:F2},{mmMin.Y:F2},{mmMin.Z:F2})..({mmMax.X:F2},{mmMax.Y:F2},{mmMax.Z:F2})"));
foreach (string line in firstMismatches) sb.AppendLine(line);
}
}
// ------------------------------------------------------------------
// The exact live query, replayed. The wedge point, plus every point on
// a 1 m lattice through the plateau, both answers side by side.
// ------------------------------------------------------------------
sb.AppendLine();
sb.AppendLine("--- the live wedge query, replayed per owner ---");
foreach (var p in placed.OrderBy(p => p.EntityId))
{
Vector3 local = Vector3.Transform(
Wedge - p.Position, Quaternion.Inverse(p.Rotation));
bool walk = FlatBspQuery.SphereIntersectsPoly(
p.Bsp, local, SphereRadius, out ushort id, out _);
bool brute = BruteHit(p.LocalPolygons, local, SphereRadius);
float nearest = float.MaxValue;
foreach (var poly in p.LocalPolygons)
nearest = MathF.Min(nearest, DistanceToPolygon(poly, local));
sb.AppendLine(Inv(
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} " +
$"walkHit={walk} (poly {id}) bruteHit={brute} nearestPoly={nearest:F3} m"));
}
sb.AppendLine();
sb.AppendLine("--- 1 m lattice over the plateau (x 120..150, y 40..70, z 44..56) ---");
foreach (var p in placed.OrderBy(p => p.EntityId))
{
int lat = 0, latWalk = 0, latBrute = 0, latMismatch = 0;
var missMin = new Vector3(float.PositiveInfinity);
var missMax = new Vector3(float.NegativeInfinity);
for (float x = 120f; x <= 150f; x += 1f)
for (float y = 40f; y <= 70f; y += 1f)
for (float z = 44f; z <= 56f; z += 1f)
{
var world = new Vector3(x, y, z);
Vector3 local = Vector3.Transform(
world - p.Position, Quaternion.Inverse(p.Rotation));
lat++;
bool walk = FlatBspQuery.SphereIntersectsPoly(
p.Bsp, local, SphereRadius, out _, out _);
bool brute = BruteHit(p.LocalPolygons, local, SphereRadius);
if (walk) latWalk++;
if (brute) latBrute++;
if (walk == brute) continue;
latMismatch++;
missMin = Vector3.Min(missMin, world);
missMax = Vector3.Max(missMax, world);
}
sb.AppendLine(Inv(
$" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} points={lat} " +
$"walkHits={latWalk} bruteHits={latBrute} MISMATCH={latMismatch}" +
(latMismatch > 0
? $" box=({missMin.X:F1},{missMin.Y:F1},{missMin.Z:F1})..({missMax.X:F1},{missMax.Y:F1},{missMax.Z:F1})"
: string.Empty)));
}
// ------------------------------------------------------------------
// The recorded live track, replayed. Each row is a position the client
// actually held, taken from 337-support.log; the Y values on the climb
// rows carry the +576 m live-centre offset that log was written in
// (live centre 0x8763, three landblocks south of 0x8766), removed here.
// For each, the walkable surface directly above/below it.
// ------------------------------------------------------------------
sb.AppendLine();
sb.AppendLine("--- recorded live positions vs the surface at their own XY ---");
(string Tag, Vector3 P, float CpNz)[] track =
[
("climb cpNz=0.7401", new Vector3(185.53f, 617.52f - 576f, 3.839f), 0.7401f),
("climb cpNz=0.7078", new Vector3(172.63f, 600.23f - 576f, 17.910f), 0.7078f),
("climb cpNz=0.8640", new Vector3(154.04f, 596.93f - 576f, 32.543f), 0.8640f),
("climb cpNz=0.8216", new Vector3(143.69f, 611.83f - 576f, 43.352f), 0.8216f),
("climb cpNz=0.9532", new Vector3(138.38f, 621.53f - 576f, 48.567f), 0.9532f),
("STALL cpNz=0.9805", new Vector3(130.81f, 625.88f - 576f, 51.097f), 0.9805f),
("stand cpNz=0.9532", new Vector3(134.08f, 623.63f - 576f, 50.074f), 0.9532f),
("stand cpNz=0.9532", new Vector3(135.77f, 625.86f - 576f, 49.994f), 0.9532f),
("stand cpNz=1.0000", new Vector3(131.38f, 627.49f - 576f, 49.908f), 1.0000f),
("STALL cpNz=0.9805", new Vector3(131.68f, 627.58f - 576f, 51.096f), 0.9805f),
("WEDGE support=none", Wedge, float.NaN),
];
foreach (var row in track)
{
var col = Column(placed, row.P.X, row.P.Y);
var up = col.Where(c => c.Nz > 0f).OrderByDescending(c => c.Z).ToList();
var upBelowHead = up.Where(c => c.Z <= row.P.Z + 0.05f).ToList();
string surf = up.Count == 0
? "NO UPWARD SURFACE IN COLUMN"
: Inv($"topUp z={up[0].Z:F3} (ent=0x{up[0].Ent:X8}) delta={row.P.Z - up[0].Z:+0.000;-0.000}");
sb.AppendLine(Inv(
$" {row.Tag} p=({row.P.X:F2},{row.P.Y:F2},{row.P.Z:F3}) " +
$"colPolys={col.Count} upFacing={up.Count} atOrBelowFeet={upBelowHead.Count} {surf}"));
}
// ------------------------------------------------------------------
// HOLE MAP. Over the plateau, at 0.5 m XY resolution, does ANY
// upward-facing physics polygon exist above z=35? A bounded region
// with none is a hole in the rock's walking surface — a body that
// reaches it stops being supported, sinks, and a corpse dropped on it
// falls through, while a jump that clears it lands fine on the far
// side. That is the reported symptom exactly.
// ------------------------------------------------------------------
sb.AppendLine();
sb.AppendLine("--- hole map: upward-facing physics coverage over the plateau ---");
sb.AppendLine(" (0.5 m XY grid, x 118..152, y 38..72; '.'=covered above z=35, "
+ "'#'=NO upward surface, '*'=the wedge XY)");
int covered = 0, holes = 0;
var holeMin = new Vector2(float.PositiveInfinity);
var holeMax = new Vector2(float.NegativeInfinity);
for (float y = 72f; y >= 38f; y -= 0.5f)
{
var line = new StringBuilder(Inv($" y={y,6:F1} "));
for (float x = 118f; x <= 152f; x += 0.5f)
{
var col = Column(placed, x, y);
bool hasUp = col.Any(c => c.Nz > 0f && c.Z > 35f);
bool isWedge = MathF.Abs(x - Wedge.X) < 0.25f
&& MathF.Abs(y - Wedge.Y) < 0.25f;
if (hasUp) covered++;
else
{
holes++;
holeMin = Vector2.Min(holeMin, new Vector2(x, y));
holeMax = Vector2.Max(holeMax, new Vector2(x, y));
}
line.Append(isWedge ? '*' : hasUp ? '.' : '#');
}
sb.AppendLine(line.ToString());
}
sb.AppendLine(Inv(
$" covered={covered} holes={holes}" +
(holes > 0
? $" holeBox=({holeMin.X:F1},{holeMin.Y:F1})..({holeMax.X:F1},{holeMax.Y:F1})"
: string.Empty)));
string outPath = Path.Combine(Path.GetTempPath(), "issue337-neftet-geometry.txt");
File.WriteAllText(outPath, sb.ToString());
_out.WriteLine(sb.ToString());
_out.WriteLine($"(also written to {outPath})");
}
// ---------------------------------------------------------------- helpers
/// <summary>
/// #337 descent replay. The live capture shows the player standing on the
/// plateau at feet z=51.096 (surface ~51.08), jumping to escape a
/// horizontal stall, and then falling STRAIGHT THROUGH that same surface —
/// four consecutive resolves accept a full-length downward step with no
/// contact plane, ending 1.1 m inside solid rock. This replays those exact
/// steps against the exact mesh, with a control step from the climb where
/// the client DID land correctly on the same mesh.
/// </summary>
[Fact]
public void ReplayTheDescentThatFellThroughTheSurface()
{
System.Globalization.CultureInfo.CurrentCulture =
System.Globalization.CultureInfo.InvariantCulture;
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
Assert.NotNull(info);
uint lbX = (Landblock >> 24) & 0xFFu;
uint lbY = (Landblock >> 16) & 0xFFu;
uint counter = 0;
var cache = new PhysicsDataCache();
var placed = new List<Placed>();
foreach (var stab in info!.Objects)
{
if (!IsSupported(stab.Id)) continue;
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
placed.AddRange(Place(dats, cache, entityId, stab.Id,
stab.Frame.Origin, stab.Frame.Orientation));
}
var sb = new StringBuilder();
// The player's verbatim dat sphere list — the same rows
// PhysicsEngine hands to SpherePath.InitPath (TS-46).
var playerSetup = dats.Get<Setup>(0x0200_0001u);
Assert.NotNull(playerSetup);
FlatSetupCollision playerFlat =
FlatCollisionAssetBuilder.FlattenSetup(playerSetup!);
sb.AppendLine("--- player setup 0x02000001 collision volume ---");
sb.AppendLine(Inv(
$" height={playerFlat.Height:F3} radius={playerFlat.Radius:F3} " +
$"stepUp={playerFlat.StepUpHeight:F3} stepDown={playerFlat.StepDownHeight:F3} " +
$"spheres={playerFlat.Spheres.Length} cylinders={playerFlat.Cylinders.Length}"));
for (int i = 0; i < playerFlat.Spheres.Length; i++)
{
var s = playerFlat.Spheres[i];
sb.AppendLine(Inv(
$" sphere[{i}] origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}"));
}
for (int i = 0; i < playerFlat.Cylinders.Length; i++)
{
var c = playerFlat.Cylinders[i];
sb.AppendLine(Inv(
$" cylsphere[{i}] origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}"));
}
// Sphere rows actually used. The live reach probe recorded
// sphereR=0.480, so this asserts the replay is using the same volume
// the client used rather than a lookalike.
var rows = playerFlat.Spheres.Length > 0
? playerFlat.Spheres.ToArray()
: playerFlat.Cylinders
.Select(c => new FlatCollisionSphere(c.Origin, c.Radius))
.ToArray();
sb.AppendLine();
sb.AppendLine("--- surface column at the fall XY ---");
foreach ((string tag, float x, float y) in new[]
{
("stand-before-jump", 131.683f, 627.581f - 576f),
("fall/landing ", 131.13f, 627.61f - 576f),
("final wedge ", 131.38f, 627.49f - 576f),
("login wedge ", Wedge.X, Wedge.Y),
})
{
var col = Column(placed, x, y).OrderByDescending(c => c.Z).ToList();
sb.AppendLine(Inv($" {tag} xy=({x:F3},{y:F3}) polys={col.Count}"));
foreach (var c in col)
{
sb.AppendLine(Inv(
$" z={c.Z,9:F3} nz={c.Nz,8:F4} ent=0x{c.Ent:X8} gfx=0x{c.Gfx:X8}"));
}
}
// The steps, feet-space, straight from 337-support.log (Y carries the
// +576 m live-centre offset that log was written in).
(string Tag, Vector3 From, Vector3 To, bool ClientHit)[] steps =
[
// Control: the climb. The client accepted these with a contact
// plane from StepSphereDown, i.e. it DID land on this mesh.
("CONTROL climb step", new Vector3(143.69f, 611.83f - 576f, 43.352f),
new Vector3(143.69f, 611.83f - 576f, 42.852f), true),
("CONTROL stand step", new Vector3(135.77f, 625.86f - 576f, 49.994f),
new Vector3(135.77f, 625.86f - 576f, 49.494f), true),
// The failing descent, verbatim, four consecutive resolves.
("FALL 1 51.389->51.269", new Vector3(131.13f, 627.61f - 576f, 51.389f),
new Vector3(131.13f, 627.61f - 576f, 51.269f), false),
("FALL 2 51.269->50.961", new Vector3(131.13f, 627.61f - 576f, 51.269f),
new Vector3(131.13f, 627.61f - 576f, 50.961f), false),
("FALL 3 50.961->50.670", new Vector3(131.13f, 627.61f - 576f, 50.961f),
new Vector3(131.13f, 627.61f - 576f, 50.670f), false),
("FALL 4 50.670->50.335", new Vector3(131.13f, 627.61f - 576f, 50.670f),
new Vector3(131.13f, 627.61f - 576f, 50.335f), false),
("FALL 5 50.335->50.021", new Vector3(131.13f, 627.61f - 576f, 50.335f),
new Vector3(131.13f, 627.61f - 576f, 50.021f), false),
("FALL 6 50.021->49.656 (client BLOCKED here)",
new Vector3(131.13f, 627.61f - 576f, 50.021f),
new Vector3(131.13f, 627.61f - 576f, 49.656f), false),
];
sb.AppendLine();
sb.AppendLine("--- swept-sphere descent replay against every owner ---");
foreach (var step in steps)
{
sb.AppendLine(Inv(
$" {step.Tag} (client recorded a contact plane: {step.ClientHit})"));
foreach (var p in placed.OrderBy(p => p.EntityId))
{
var invRot = Quaternion.Inverse(p.Rotation);
for (int i = 0; i < rows.Length; i++)
{
Vector3 worldFrom = step.From + rows[i].Origin;
Vector3 worldTo = step.To + rows[i].Origin;
Vector3 localTo = Vector3.Transform(worldTo - p.Position, invRot);
Vector3 localFrom = Vector3.Transform(worldFrom - p.Position, invRot);
Vector3 localMove = localTo - localFrom;
bool swept = FlatBspQuery.SphereIntersectsPolyWithTime(
p.Bsp, localTo, rows[i].Radius, localMove,
out ushort sweptId, out _, out float sweptTime);
bool stat = FlatBspQuery.SphereIntersectsPoly(
p.Bsp, localTo, rows[i].Radius, out ushort statId, out _);
float nearest = float.MaxValue;
foreach (var poly in p.LocalPolygons)
nearest = MathF.Min(nearest, DistanceToPolygon(poly, localTo));
if (!swept && !stat && nearest > 3f) continue; // far away, silent
sb.AppendLine(Inv(
$" ent=0x{p.EntityId:X8} sphere[{i}] r={rows[i].Radius:F3} " +
$"swept={swept}(poly {sweptId} t={sweptTime:F4}) " +
$"static={stat}(poly {statId}) nearestPoly={nearest:F3} m"));
}
}
}
string outPath = Path.Combine(Path.GetTempPath(), "issue337-descent-replay.txt");
File.WriteAllText(outPath, sb.ToString());
_out.WriteLine(sb.ToString());
_out.WriteLine($"(also written to {outPath})");
}
/// <summary>
/// #337 REPRODUCER. At the position the client fell through the plateau,
/// the BSP query returns a hit — the geometry is present, correctly
/// placed, and reachable by the production traversal. This asserts that
/// load-bearing fact, which must hold both before and after any fix; if it
/// ever stops holding, the diagnosis in
/// docs/research/2026-08-06-337-neftet-wedge-mechanism.md is void.
/// </summary>
[Fact]
public void TheBspQueryReturnsAHitAtThePositionTheClientFellThrough()
{
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) return; // CI without dats — the sibling dumps skip too.
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
Assert.NotNull(info);
Placed rock = ResolveOwner(dats, info!, 0xC876_6009u);
// Feet position and per-sphere layout, verbatim from the live capture
// (337-support.log t=303221015..303221125) and Setup 0x02000001.
var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f);
var footSphereOffset = new Vector3(0f, 0f, 0.475f);
const float SphereRadius = 0.48f;
Vector3 local = Vector3.Transform(
feet + footSphereOffset - rock.Position,
Quaternion.Inverse(rock.Rotation));
bool hit = FlatBspQuery.SphereIntersectsPoly(
rock.Bsp, local, SphereRadius, out ushort polygonId, out _);
Assert.True(
hit,
"The rock's physics BSP must report the plateau surface under the "
+ "mover at the position the live client fell through it. If this "
+ "fails, the defect is in the geometry or the traversal after all.");
Assert.NotEqual(0, polygonId);
}
/// <summary>
/// #337 EVIDENCE — the installed-DAT measurement that condemned the
/// query-site broadphase, pinned so the diagnosis stays checkable.
///
/// <para>
/// The deleted per-object filter in
/// <c>Transition.FindObjCollisionsInCell</c> measured the mover's distance
/// to the shadow entry's <c>Position</c> — the part ORIGIN — and compared
/// it against <c>obj.Radius</c>, 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.
/// </para>
///
/// <para>
/// Retail has no such filter. <c>CPartArray::FindObjCollisions</c>
/// @0x00518180 is a bare loop over parts and
/// <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 does two null
/// checks and calls <c>CGfxObj::find_obj_collisions</c> @0x00534700 —
/// 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. #333 therefore
/// deleted the filter outright rather than re-centring it.
/// </para>
///
/// <para>
/// This test asserts the DATA, not the production predicate — the
/// production gate is
/// <c>Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover</c>,
/// which drives <c>ResolveWithTransition</c> 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.
/// </para>
/// </summary>
[Fact]
public void TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn()
{
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = dats.Get<LandBlockInfo>((Landblock & 0xFFFF0000u) | 0xFFFEu);
Assert.NotNull(info);
Placed rock = ResolveOwner(dats, info!, 0xC876_6009u);
var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f);
Vector3 currPos = feet + new Vector3(0f, 0f, 0.475f);
const float SphereRadius = 0.48f;
const float Movement = 0.308f; // the live step length
var root = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere;
float ownerRadius = root.Radius;
// Verbatim from the deleted predicate.
float distToOrigin = (currPos - rock.Position).Length();
float maxReach = SphereRadius + ownerRadius + Movement + 2f;
Assert.True(
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(
DatCollection dats, LandBlockInfo info, uint wantedEntityId)
{
uint lbX = (Landblock >> 24) & 0xFFu;
uint lbY = (Landblock >> 16) & 0xFFu;
uint counter = 0;
var cache = new PhysicsDataCache();
foreach (var stab in info.Objects)
{
if (!IsSupported(stab.Id)) continue;
uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter);
if (entityId != wantedEntityId) continue;
var parts = Place(dats, cache, entityId, stab.Id,
stab.Frame.Origin, stab.Frame.Orientation).ToList();
Assert.Single(parts);
return parts[0];
}
throw new InvalidOperationException(
$"landblock static 0x{wantedEntityId:X8} not found.");
}
/// <summary>
/// Pass-through. Concatenated interpolated fragments cannot bind to
/// <see cref="FormattableString"/>, so the test method pins
/// <c>CurrentCulture</c> to invariant instead and this marks the call
/// sites that depend on it.
/// </summary>
private static string Inv(string s) => s;
private static uint LandblockStaticEntityIdAllocatorAllocate(
uint lbX, uint lbY, ref uint counter)
=> LandblockStaticEntityIdAllocator.Allocate(lbX, lbY, ref counter);
private static bool IsSupported(uint id)
{
uint type = id & TypeMask;
return type == GfxObjMask || type == SetupMask;
}
private static IEnumerable<Placed> Place(
DatCollection dats,
PhysicsDataCache cache,
uint entityId,
uint datId,
Vector3 origin,
Quaternion orientation)
{
var parts = new List<(uint GfxObjId, Vector3 LocalPos, Quaternion LocalRot)>();
if ((datId & TypeMask) == GfxObjMask)
{
parts.Add((datId, Vector3.Zero, Quaternion.Identity));
}
else
{
var setup = dats.Get<Setup>(datId);
if (setup is null) yield break;
for (int i = 0; i < setup.Parts.Count; i++)
{
Vector3 lp = Vector3.Zero;
Quaternion lr = Quaternion.Identity;
if (!setup.PlacementFrames.TryGetValue(
DatReaderWriter.Enums.Placement.Resting, out var pf)
&& !setup.PlacementFrames.TryGetValue(
DatReaderWriter.Enums.Placement.Default, out pf))
{
pf = setup.PlacementFrames.Values.FirstOrDefault();
}
if (pf?.Frames is not null && i < pf.Frames.Count)
{
lp = pf.Frames[i].Origin;
lr = pf.Frames[i].Orientation;
}
parts.Add((setup.Parts[i], lp, lr));
}
}
foreach (var part in parts)
{
var gfx = dats.Get<GfxObj>(part.GfxObjId);
if (gfx is null) continue;
cache.CacheGfxObj(part.GfxObjId, gfx);
FlatGfxObjCollisionAsset flat =
FlatCollisionAssetBuilder.FlattenGfxObj(gfx);
FlatPhysicsBsp bsp = flat.PhysicsBsp;
if (bsp.RootIndex < 0 || bsp.Nodes.Length == 0) continue;
Vector3 partWorldPos = origin + Vector3.Transform(part.LocalPos, orientation);
Quaternion partWorldRot = orientation * part.LocalRot;
var seen = new HashSet<int>();
var worldPolys = new List<Vector3[]>();
var localPolys = new List<Vector3[]>();
var min = new Vector3(float.PositiveInfinity);
var max = new Vector3(float.NegativeInfinity);
foreach (var node in bsp.Nodes)
{
var range = node.PolygonIndexRange;
for (int i = range.Start; i < range.EndExclusive; i++)
{
int pi = bsp.PolygonIndexStream[i];
if ((uint)pi >= (uint)bsp.PolygonTable.Polygons.Length) continue;
if (!seen.Add(pi)) continue;
var poly = bsp.PolygonTable.Polygons[pi];
var vr = poly.VertexRange;
var verts = new Vector3[vr.Count];
var lverts = new Vector3[vr.Count];
for (int v = 0; v < vr.Count; v++)
{
Vector3 local = bsp.PolygonTable.Vertices[vr.Start + v];
Vector3 world = partWorldPos
+ Vector3.Transform(local, partWorldRot);
lverts[v] = local;
verts[v] = world;
min = Vector3.Min(min, world);
max = Vector3.Max(max, world);
}
worldPolys.Add(verts);
localPolys.Add(lverts);
}
}
if (worldPolys.Count == 0) continue;
yield return new Placed(
entityId, datId, part.GfxObjId, partWorldPos, partWorldRot,
worldPolys, localPolys, bsp, min, max,
bsp.Nodes.Length, worldPolys.Count);
}
}
/// <summary>
/// Points just off the mesh surface, both sides, at every vertex and
/// centroid. A sphere centred here demonstrably contains mesh, so the
/// production walk must report a hit.
/// </summary>
private static IEnumerable<Vector3> SurfaceProbes(
List<Vector3[]> polygons, float radius)
{
float nudge = radius * 0.5f;
foreach (Vector3[] poly in polygons)
{
if (poly.Length < 3) continue;
Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]);
float len = n.Length();
if (len < 1e-9f) continue;
n /= len;
Vector3 centroid = Vector3.Zero;
foreach (Vector3 v in poly) centroid += v;
centroid /= poly.Length;
yield return centroid + n * nudge;
yield return centroid - n * nudge;
foreach (Vector3 v in poly)
{
// Pull slightly toward the centroid so a vertex probe sits on
// the polygon rather than exactly on its corner.
Vector3 inset = Vector3.Lerp(v, centroid, 0.15f);
yield return inset + n * nudge;
yield return inset - n * nudge;
}
}
}
private static bool BruteHit(List<Vector3[]> polygons, Vector3 centre, float radius)
{
foreach (Vector3[] poly in polygons)
{
if (DistanceToPolygon(poly, centre) < radius - 1e-4f)
return true;
}
return false;
}
/// <summary>
/// Every physics polygon whose world XY projection contains
/// (<paramref name="x"/>, <paramref name="y"/>), with the Z of its plane
/// at that XY and the Z component of its normal.
/// </summary>
private static List<(uint Ent, uint Gfx, float Z, float Nz)> Column(
List<Placed> placed, float x, float y)
{
var result = new List<(uint, uint, float, float)>();
foreach (var p in placed)
{
if (x < p.WorldMin.X || x > p.WorldMax.X
|| y < p.WorldMin.Y || y > p.WorldMax.Y) continue;
foreach (Vector3[] poly in p.WorldPolygons)
{
if (!XyContains(poly, x, y)) continue;
if (!TryPlaneZ(poly, x, y, out float z, out float nz)) continue;
result.Add((p.EntityId, p.GfxObjId, z, nz));
}
}
return result;
}
private static bool Inside(Vector3 min, Vector3 max, Vector3 p)
=> p.X >= min.X && p.X <= max.X
&& p.Y >= min.Y && p.Y <= max.Y
&& p.Z >= min.Z && p.Z <= max.Z;
private static float YawDegrees(Quaternion q)
{
// Rotation of +X about Z only; indicative for the mostly-Z-only
// orientations landblock stabs carry.
Vector3 x = Vector3.Transform(Vector3.UnitX, q);
return MathF.Atan2(x.Y, x.X) * 180f / MathF.PI;
}
private static bool XyContains(Vector3[] poly, float x, float y)
{
bool inside = false;
for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
{
float yi = poly[i].Y, yj = poly[j].Y;
if ((yi > y) == (yj > y)) continue;
float t = (y - yi) / (yj - yi);
float xAt = poly[i].X + t * (poly[j].X - poly[i].X);
if (x < xAt) inside = !inside;
}
return inside;
}
private static bool TryPlaneZ(Vector3[] poly, float x, float y, out float z, out float nz)
{
z = 0f; nz = 0f;
if (poly.Length < 3) return false;
Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]);
float len = n.Length();
if (len < 1e-9f) return false;
n /= len;
if (MathF.Abs(n.Z) < 1e-6f) return false;
nz = n.Z;
// n · (P - poly0) = 0 -> z = poly0.z - (n.x*(x-p0.x) + n.y*(y-p0.y)) / n.z
z = poly[0].Z - (n.X * (x - poly[0].X) + n.Y * (y - poly[0].Y)) / n.Z;
return true;
}
private static float DistanceToPolygon(Vector3[] poly, Vector3 p)
{
float best = float.MaxValue;
for (int i = 2; i < poly.Length; i++)
best = MathF.Min(best, DistanceToTriangle(poly[0], poly[i - 1], poly[i], p));
if (poly.Length == 2)
best = MathF.Min(best, DistanceToSegment(poly[0], poly[1], p));
return best;
}
private static float DistanceToTriangle(Vector3 a, Vector3 b, Vector3 c, Vector3 p)
{
Vector3 ab = b - a, ac = c - a, ap = p - a;
float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap);
if (d1 <= 0 && d2 <= 0) return Vector3.Distance(p, a);
Vector3 bp = p - b;
float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp);
if (d3 >= 0 && d4 <= d3) return Vector3.Distance(p, b);
float vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0)
return Vector3.Distance(p, a + ab * (d1 / (d1 - d3)));
Vector3 cp = p - c;
float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp);
if (d6 >= 0 && d5 <= d6) return Vector3.Distance(p, c);
float vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0)
return Vector3.Distance(p, a + ac * (d2 / (d2 - d6)));
float va = d3 * d6 - d5 * d4;
if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0)
return Vector3.Distance(p, b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6))));
float denom = 1f / (va + vb + vc);
return Vector3.Distance(p, a + ab * (vb * denom) + ac * (vc * denom));
}
private static float DistanceToSegment(Vector3 a, Vector3 b, Vector3 p)
{
Vector3 ab = b - a;
float t = Vector3.Dot(p - a, ab) / MathF.Max(1e-9f, Vector3.Dot(ab, ab));
t = Math.Clamp(t, 0f, 1f);
return Vector3.Distance(p, a + ab * t);
}
}

View file

@ -26,19 +26,19 @@ public class ShadowObjectRegistryMultiPartTests
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1.0f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)),
ShadowShape.Bsp(
gfxObjId: 0x010044B6u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1.0f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)),
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)),
ShadowShape.Bsp(
gfxObjId: 0x010044B6u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1.0f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f))
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null))
};
[Fact]
@ -260,9 +260,11 @@ public class ShadowObjectRegistryMultiPartTests
localPosition: localPosition,
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
scale: 1f,
localBounds: new FlatCollisionSphere(
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
radius));
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(
boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter,
radius),
null));
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
{
@ -444,7 +446,9 @@ public class ShadowObjectRegistryMultiPartTests
setup,
entScale: 1f,
hasPhysicsBsp: id => id == part,
physicsBspBounds: id => id == part ? bounds : null);
physicsBspBounds: id => id == part
? ShadowPartGeometry.Create(bounds, null)
: (ShadowPartGeometry?)null);
ShadowShape only = Assert.Single(shapes);
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);

View file

@ -973,7 +973,7 @@ public class ShadowObjectRegistryTests
Vector3.Zero,
Quaternion.Identity,
scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, radius));
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, radius), null));
private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform)
{

View file

@ -467,5 +467,5 @@ public sealed class ShadowSetPositionCommitTests
local,
Quaternion.Identity,
scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, 0.25f));
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 0.25f), null));
}

View file

@ -0,0 +1,120 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #337 (2026-08-06 — TEMPORARY, delete with the <c>[support]</c> probe).
///
/// <para>
/// The <c>[support]</c> line's whole value is its <c>support=</c> verdict:
/// terrain, an object surface, or nothing. If that classifier is wrong, a
/// capture does not merely fail to answer — it answers CONFIDENTLY WRONG, and
/// this campaign has already spent two diagnoses on confident wrong answers.
/// These cover the decision boundaries directly, so the live capture can be
/// read at face value.
/// </para>
/// </summary>
public sealed class SupportProbeClassifierTests
{
private const float FlatNormalZ = 1f;
[Fact]
public void NoContactPlane_IsUnsupported()
{
Assert.Equal(
"none",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: false,
terrainSampled: true,
contactPlaneZAtXY: 100f,
contactPlaneNormalZ: FlatNormalZ,
terrainZ: 100f,
terrainNormalZ: FlatNormalZ));
}
[Fact]
public void PlaneAtTerrainHeightAndTilt_IsTerrain()
{
Assert.Equal(
"terrain",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: true,
contactPlaneZAtXY: 41.25f,
contactPlaneNormalZ: 0.94f,
terrainZ: 41.26f,
terrainNormalZ: 0.94f));
}
[Fact]
public void PlaneWellAboveTerrain_IsObject()
{
// The rock-plateau shape: the body rests six metres above the ground.
Assert.Equal(
"object",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: true,
contactPlaneZAtXY: 47.5f,
contactPlaneNormalZ: FlatNormalZ,
terrainZ: 41.5f,
terrainNormalZ: 0.9f));
}
[Fact]
public void SameHeightDifferentTilt_IsReportedSeparately()
{
// A collision surface lying flat against sloped ground. This must NOT
// collapse into either answer: it is precisely the ambiguous case, and
// guessing between them is what the probe exists to avoid.
Assert.Equal(
"coplanar-tilt-mismatch",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: true,
contactPlaneZAtXY: 41.5f,
contactPlaneNormalZ: 1.0f,
terrainZ: 41.5f,
terrainNormalZ: 0.72f));
}
[Fact]
public void NoTerrainUnderTheBody_SaysSoRatherThanGuessing()
{
Assert.Equal(
"no-terrain",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: false,
contactPlaneZAtXY: 12f,
contactPlaneNormalZ: FlatNormalZ,
terrainZ: float.NaN,
terrainNormalZ: float.NaN));
}
[Fact]
public void PlaneHeightIsEvaluatedAtTheBodysOwnXy()
{
// A 45-degree ramp through the origin: height must track X, or a body
// standing on a slope would read as displaced from its own support.
var slope = new Plane(Vector3.Normalize(new Vector3(-1f, 0f, 1f)), 0f);
Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 0f, 0f, out float atOrigin));
Assert.Equal(0f, atOrigin, 3);
Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 10f, 0f, out float atTen));
Assert.Equal(10f, atTen, 3);
}
[Fact]
public void VerticalPlaneHasNoHeight()
{
// A wall is never a floor. Reporting a height for one would read as a
// wildly displaced surface and manufacture a false positive.
var wall = new Plane(new Vector3(1f, 0f, 0f), -5f);
Assert.False(PhysicsDiagnostics.TryPlaneZAt(wall, 0f, 0f, out _));
}
}