# #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 BuildShadowCellSetFromParts( PhysicsDataCache cache, uint seedCellId, IReadOnlyList 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` and `FromLandblockBspParts`'s `Func 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>` 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.