acdream/docs/research/2026-08-06-334-contract.md
Erik 13fcf38138 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>
2026-08-06 19:07:06 +02:00

38 KiB
Raw Permalink Blame History

#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 sphere3×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_cellsadd_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: 0x005152300x00510fc00x00518160[vtbl+0x7c]0x005338400x005333600x005331d0.

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.