fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)

acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 19:07:06 +02:00
parent f0588725cf
commit 13fcf38138
19 changed files with 2186 additions and 137 deletions

View file

@ -24,6 +24,31 @@ 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.
## #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:** OPEN
@ -1741,6 +1766,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 +10603,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