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>
774 lines
38 KiB
Markdown
774 lines
38 KiB
Markdown
# #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_list’s 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.2’s 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
|
||
|
||
~600–800 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 | T1–T10; P1–P5; 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.
|