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. - 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. - 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 ## #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 **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 the radar, same defect class, same fix shape (mirror the property into the
bitfield/snapshot at its source). Filed from the #297 delta review. 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` - **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`
is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of 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 `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) ## #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. **Severity:** HIGH — walk-through and fall-through on world geometry.
**Filed:** 2026-08-06, user-reported in live play. **Filed:** 2026-08-06, user-reported in live play.
**Component:** physics / collision / broadphase **Component:** physics / collision / broadphase

File diff suppressed because one or more lines are too long

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

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

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) private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
{ {
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check, // CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
@ -500,8 +640,13 @@ public static class CellTransit
} }
/// <summary> /// <summary>
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time. /// 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, /// Verbatim port of <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0,
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> / /// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160): /// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
@ -657,6 +802,184 @@ public static class CellTransit
return candidates.OrderedIds; 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> /// <summary>
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c> /// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP /// (<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); _visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
} }
GfxObjVisualBounds? parsedBounds =
_visualBounds.TryGetValue(gfxObjId, out var cachedBounds)
? cachedBounds
: null;
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing)) if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
{ {
@ -217,6 +221,10 @@ public sealed class PhysicsDataCache
Vertices = gfxObj.VertexArray, Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
FlatPhysicsBsp = prepared?.PhysicsBsp, 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; _gfxObj[gfxObjId] = physics;
@ -285,6 +293,7 @@ public sealed class PhysicsDataCache
Radius = root.Radius, Radius = root.Radius,
}, },
FlatPhysicsBsp = prepared.PhysicsBsp, FlatPhysicsBsp = prepared.PhysicsBsp,
VisualBounds = prepared.VisualBounds,
}); });
} }
@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics
/// omit it. /// omit it.
/// </summary> /// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; } 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> /// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>

View file

@ -430,12 +430,29 @@ public sealed class ShadowObjectRegistry
/// ///
/// <para> /// <para>
/// BR-7: the cell set is ONE flood for the whole entity (retail floods /// 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 /// per OBJECT, not per part). WHICH flood is retail's own exclusive
/// follow <c>CPhysicsObj::calc_cross_cells</c>' own EXCLUSIVE priority — /// dispatch on <c>HAS_PHYSICS_BSP_PS</c>
/// physics-BSP parts, else CylSpheres, else the remaining shapes — see /// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230,
/// <see cref="BuildFloodSpheres"/> for the disassembly. A BSP part /// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// contributes its ROOT BOUNDING SPHERE placed at its real center /// <c>0x0051528f jne 0x515305</c>):
/// (<see cref="ShadowShape.BoundsCenter"/>), not at the part origin. /// </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 /// Every shape row is then written into every flooded cell, mirroring
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow. /// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
/// </para> /// </para>
@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId); : DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return; if (seed == 0u) return;
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); // Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
var cellSet = CellTransit.BuildShadowCellSet( // (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); // 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; if (cellSet.Count == 0) return;
DeregisterCore(entityId, publishMutation: false); DeregisterCore(entityId, publishMutation: false);
@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry
} }
/// <summary> /// <summary>
/// Retail cross-cell dispatch, <c>CPhysicsObj::calc_cross_cells</c> /// Flood spheres for an object with NO physics BSP — retail's cylsphere
/// @0x00515230, in retail's own priority order: /// 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"> /// <list type="number">
/// <item>BSP-bearing (<c>0x00515285 test dword [esi+0xa8],0x10000</c> / /// <item>cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
/// <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) →
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array; /// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
/// each contributes one sphere at its world BASE point with the cylinder /// each contributes one sphere at its world BASE point with the cylinder
/// radius, capped at 10.</item> /// radius, capped at 10.</item>
@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry
/// </list> /// </list>
/// ///
/// <para> /// <para>
/// The BSP-first rule is redundant for every shape list acdream produces /// #334: there is no BSP arm here any more, and there must not be one.
/// today — <see cref="ShadowShapeBuilder.FromSetup"/> dispatches at /// The BSP branch is a structurally different algorithm over BOXES
/// emission (AP-152) and both landblock-static publishers emit /// (<see cref="CellTransit.BuildShadowCellSetFromParts"/>), and
/// homogeneous lists — exactly as /// <see cref="RegisterMultiPart"/> routes to it before this method is
/// <c>Transition.BspOnlyDispatch</c> is redundant at the query site. It is /// reached. The arm this method used to carry — "a BSP part contributes
/// kept because retail genuinely dispatches here, and because a producer /// its ROOT BOUNDING SPHERE placed at its real center" — described
/// that handed this method a mixed list would otherwise flood a /// retail's INDOOR portal reject, not its outdoor expansion, and using it
/// BSP-bearing object from its primitive and silently place it in the /// for both is what capped every BSP object's outdoor reach at a 3×3
/// wrong shadow cells (the #98 / #168 symptom class). /// 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> /// </para>
/// </summary> /// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres( private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry
const int RetailSphereCap = 10; const int RetailSphereCap = 10;
var spheres = new List<DatReaderWriter.Types.Sphere>(); var spheres = new List<DatReaderWriter.Types.Sphere>();
bool anyBsp = false;
bool anyCyl = false; bool anyCyl = false;
foreach (var s in shapes) foreach (var s in shapes)
{ {
if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true; if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
} }
// Retail's branch, chosen once: BSP-bbox, else cylspheres, else the // Retail's branch, chosen once: cylspheres, else the sorting sphere
// sorting sphere (which acdream approximates with the remaining // (which acdream approximates with the Sphere shapes — AP-157).
// shapes' bounding spheres — AP-157). ShadowCollisionType only =
ShadowCollisionType? only = anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere;
anyBsp ? ShadowCollisionType.BSP
: anyCyl ? ShadowCollisionType.Cylinder
: null;
// The 10-sphere clamp belongs to the CYLSPHERE branch alone. // The 10-sphere clamp belongs to the CYLSPHERE branch alone.
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at // 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 // fixed static-buffer capacity (the destination array at
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy. // 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 // Sorting-sphere branch: int.MaxValue is NOT a retail port and the
// @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 -> // addresses above do not justify it. Retail's overload @0x0052b990
// CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded // pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored
// only by num_parts. Clamping it dropped parts 11..N out of the flood // CSetup::sorting_sphere. acdream floods from every Sphere shape
// entirely: 7 installed Setups carry more than 10 physics-BSP parts // instead — a different DAT field with a different cardinality, which
// (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair // is AP-157, filed and open. Capping at 1 HERE would not move toward
// runs, fences, rock clusters — routinely do. // retail: it would take Spheres[0], which is not the sorting sphere.
// // int.MaxValue keeps the substitution in its safe (over-inclusive)
// only == null (the sorting-sphere branch): int.MaxValue is NOT a // direction until AP-157 ports the real field. Inert over installed
// retail port and the addresses above do not justify it. Retail's // data — max 5 Spheres on any Setup (0x020016F7).
// 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; int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
foreach (var s in shapes) foreach (var s in shapes)
{ {
if (only is { } required && s.CollisionType != required) if (s.CollisionType != only)
continue; continue;
if (spheres.Count >= cap) if (spheres.Count >= cap)
break; break;
// Place the sphere where the GEOMETRY is, not where the part // A primitive's LocalPosition already IS its centre, so
// origin is. Composed exactly as the ShadowEntry rows below are // BoundsCenter is Zero; the composition is kept identical to the
// (partWorldPos / partWorldRot), then offset by the shape's own // emitted ShadowEntry rows so the flood and the geometry can never
// BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0 // disagree about where the shape is.
// 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.
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot); var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation; var partWorldRot = entityWorldRot * s.LocalRotation;
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot); var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry
return spheres; 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> /// <summary>
/// Derive the outdoor landcell id under a world position — the implicit /// Derive the outdoor landcell id under a world position — the implicit
/// seed for landblock-baked statics registered without a cell id /// 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"/>, /// CONSTRUCTION IS BY FACTORY ONLY (<see cref="Bsp"/>, <see cref="Cylinder"/>,
/// <see cref="Sphere"/>) and the constructor is private. That is the AP-156 /// <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 /// invariant expressed at the type rather than only at the producer: a BSP
/// shape's radius and its bounding-sphere CENTRE arrive as one /// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX
/// <see cref="FlatCollisionSphere"/> value and are scaled together inside /// arrive as one <see cref="ShadowPartGeometry"/> value and are scaled together
/// <see cref="Bsp"/>, so no call site — present or future — can take the /// inside <see cref="Bsp"/>, so no call site — present or future — can take one
/// radius while dropping the origin. That split is exactly what produced /// while dropping another. Those splits are exactly what produced AP-156 (the
/// AP-156, and with the old public 7-argument constructor a new BSP producer /// centre dropped) and #334 (the box never resolved at all); with a public
/// could have reintroduced it silently and green. /// positional constructor a new BSP producer could reintroduce either silently
/// and green.
/// </para> /// </para>
/// </summary> /// </summary>
public readonly record struct ShadowShape public readonly record struct ShadowShape
@ -38,7 +39,9 @@ public readonly record struct ShadowShape
ShadowCollisionType collisionType, ShadowCollisionType collisionType,
float radius, float radius,
float cylHeight, float cylHeight,
Vector3 boundsCenter) Vector3 boundsCenter,
Vector3 localBoundsMin,
Vector3 localBoundsMax)
{ {
GfxObjId = gfxObjId; GfxObjId = gfxObjId;
LocalPosition = localPosition; LocalPosition = localPosition;
@ -48,6 +51,8 @@ public readonly record struct ShadowShape
Radius = radius; Radius = radius;
CylHeight = cylHeight; CylHeight = cylHeight;
BoundsCenter = boundsCenter; BoundsCenter = boundsCenter;
LocalBoundsMin = localBoundsMin;
LocalBoundsMax = localBoundsMax;
} }
/// <summary>Source GfxObj id, for the BSP walk and for diagnostics.</summary> /// <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; } public Vector3 BoundsCenter { get; }
/// <summary> /// <summary>
/// One physics-BSP part. <paramref name="localBounds"/> is the part /// The shape's AXIS-ALIGNED BOX in the same local frame as
/// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame, /// <see cref="BoundsCenter"/>, already entity-scaled. For a BSP shape this
/// unscaled — retail's <c>CGfxObj::physics_sphere</c>. Radius and centre /// is retail's <c>CGfxObj::gfx_bound_box</c> — the AABB of the GfxObj's
/// are scaled together here, which is the whole point of taking them as /// vertex array, which <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
/// one value. /// 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> /// </summary>
public static ShadowShape Bsp( public static ShadowShape Bsp(
uint gfxObjId, uint gfxObjId,
Vector3 localPosition, Vector3 localPosition,
Quaternion localRotation, Quaternion localRotation,
float scale, float scale,
FlatCollisionSphere localBounds) ShadowPartGeometry localGeometry)
=> new( => new(
gfxObjId, gfxObjId,
localPosition, localPosition,
localRotation, localRotation,
scale, scale,
ShadowCollisionType.BSP, ShadowCollisionType.BSP,
localBounds.Radius * scale, localGeometry.Sphere.Radius * scale,
0f, 0f,
localBounds.Origin * scale); localGeometry.Sphere.Origin * scale,
localGeometry.BoxMin * scale,
localGeometry.BoxMax * scale);
/// <summary> /// <summary>
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the /// One Setup CylSphere. <paramref name="localPosition"/> already IS the
@ -148,7 +177,9 @@ public readonly record struct ShadowShape
ShadowCollisionType.Cylinder, ShadowCollisionType.Cylinder,
radius, radius,
cylHeight, cylHeight,
Vector3.Zero); Vector3.Zero,
new Vector3(-radius, -radius, 0f),
new Vector3(radius, radius, cylHeight));
/// <summary> /// <summary>
/// One Setup Sphere. <paramref name="localPosition"/> already IS the /// One Setup Sphere. <paramref name="localPosition"/> already IS the
@ -168,5 +199,7 @@ public readonly record struct ShadowShape
ShadowCollisionType.Sphere, ShadowCollisionType.Sphere,
radius, radius,
0f, 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. /// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.</param> /// Null or short lists fall back to the Setup identity.</param>
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT /// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
/// bounding sphere — retail's <c>CGfxObj::physics_sphere</c>, which is /// bounding sphere AND its authored vertex-array box — retail's
/// literally <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0. Supplies /// <c>CGfxObj::physics_sphere</c> (<c>BSPTREE::GetSphere(physics_bsp)</c>
/// BOTH the emitted <see cref="ShadowShape.Radius"/> and its /// @0x005397e0) and <c>CGfxObj::gfx_bound_box</c>
/// <see cref="ShadowShape.BoundsCenter"/>, from one call, so the sphere's /// (<c>CPhysicsPart::GetBoundingBox</c> @0x0050d600), as ONE
/// size can never be carried while its position is dropped. Null (or a /// <see cref="ShadowPartGeometry"/>. Supplies the emitted
/// null result) falls back to the loose-but-safe 2 m placeholder at the /// <see cref="ShadowShape.Radius"/>, <see cref="ShadowShape.BoundsCenter"/>
/// part origin — a fixture-only configuration; production always supplies /// and <see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c> from one call,
/// it (<c>LiveEntityCollisionBuilder</c>).</param> /// 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( public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup, Setup setup,
float entScale, float entScale,
Func<uint, bool> hasPhysicsBsp, Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null, IReadOnlyList<Frame>? partPoseOverride = null,
IReadOnlyList<uint>? effectivePartGfxObjIds = 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 (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); 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. // supplies both so one cannot be taken without the other.
// Absent bounds keep the loose-but-safe 2 m placeholder, centred // Absent bounds keep the loose-but-safe 2 m placeholder, centred
// on the part origin because nothing better is known. // on the part origin because nothing better is known.
// ShadowShape.Bsp scales radius and centre together. // ShadowShape.Bsp scales radius, centre and box together.
FlatCollisionSphere bounds = //
// #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) physicsBspBounds?.Invoke(gfxId)
?? new FlatCollisionSphere(Vector3.Zero, 2f); ?? ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, 2f),
null);
result.Add(ShadowShape.Bsp( result.Add(ShadowShape.Bsp(
gfxObjId: gfxId, gfxObjId: gfxId,
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
localRotation: partFrame.Orientation, localRotation: partFrame.Orientation,
scale: entScale, scale: entScale,
localBounds: bounds)); localGeometry: geometry));
} }
return result; return result;
@ -308,12 +322,21 @@ public static class ShadowShapeBuilder
phys.BoundingSphere?.Origin ?? Vector3.Zero, phys.BoundingSphere?.Origin ?? Vector3.Zero,
phys.BoundingSphere?.Radius ?? 1f); 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( shapes.Add(ShadowShape.Bsp(
gfxObjId: meshRef.GfxObjId, gfxObjId: meshRef.GfxObjId,
localPosition: pPos, localPosition: pPos,
localRotation: pRot, localRotation: pRot,
scale: partScale, scale: partScale,
localBounds: localBounds)); localGeometry: geometry));
} }
return shapes; return shapes;

View file

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

View file

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

View file

@ -339,7 +339,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
setup, setup,
EntScale, EntScale,
id => Bounds(id) is not null, 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 // ShadowObjectRegistry.BuildFloodSpheres' composition, at an
// entity placed at the world origin with identity rotation. // 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.LocalPosition,
s.LocalRotation, s.LocalRotation,
s.Scale, s.Scale,
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale))); ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale),
null)));
} }
else else
{ {
@ -1113,7 +1115,7 @@ public class DoorBugTrajectoryReplayTests
localPosition: Vector3.Zero, localPosition: Vector3.Zero,
localRotation: Quaternion.Identity, localRotation: Quaternion.Identity,
scale: 1f, scale: 1f,
localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius)); localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, BspRadius), null));
var cylShape = ShadowShape.Cylinder( var cylShape = ShadowShape.Cylinder(
gfxObjId: 0u, gfxObjId: 0u,

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

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

View file

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

View file

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