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>
38 KiB
#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:
- The rectangle is filled, not outlined. An L-shaped or diagonal object claims cells its geometry never enters. Retail's coverage is deliberately conservative.
x/yare 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.add_cell(0x006b4ff0) dedups by id via a linear scan and stores theLScapepointer beside it — includingnullfor 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 witheps = 0.0002 + radius(0x0052cb65) — a cheap reject; - on pass,
BBox::LocalToLocal(partBBox, part->pos, cell->pos)thenPlane::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 == 0xFFFFFFFFsets a flag meaning this portal leads outside; otherwiseBBox::LocalToLocalinto the destination cell andCCellStruct::box_intersects_cellgates 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.
BoundsCenterstays. It still positions the sphere for the non-BSP routes and for theeps = 0.0002 + radiusportal 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:
RuntimeRemotePhysicsUpdaterre-floods per tick, gated on1 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:
- histogram of
ceil(Xextent/24)+1×ceil(Yextent/24)+1; - the count exceeding 1×1, 2×2 and 4×4;
- the worst case, with its gfx id;
- total
Σ shapes × cellsover 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:
- a
[reach-obj]row withgfx=0x010046D8appears in cells where it did not before; - that row's
dispistested-*, notrejected-reach(§3.3); [reach-q]'sinCellrises andrejectedReachdoes 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=2with 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
- AP-158 masks the fix in far cells. §3.3. The most likely way this lands green and changes nothing the user can see.
- 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.
0x00518160is not the extent walk. It is a 3-instruction vtable thunk. The extent walk is0x00533360. #334's issue body cites the former as "a walk over the object's extent" (§9.2).- Two functions named
calc_cross_cells_static.CPhysicsObj::@0x00515160is a caller offind_bbox_cell_list;CPartArray::@0x00518160is the thunk below it. They are not on the same level and confusing them inverts the call graph. floor, not truncation. Retail callsfloorthen_ftol2. C#(int)(v / 24f)truncates toward zero and is wrong for every negative block-local coordinate. T9.- The base gid comes from the FIRST NON-NULL PART's
adjust_to_outside, not from the object's position (0x005333a2).DeriveOutdoorSeedclamps to the seed block; the rectangle must not inherit that clamp. - Global vs within-landblock indices in the same expression.
gid_to_lcoordreturns global coords;baseX/baseYare 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 theand eax,0xffffthat makesbaseXwithin-block (§9.4). - Do not touch
AddOutsideCell. It is already correct and already landblock-crossing; the new path composes it. - The
isStaticprune is indoor-seeded only. The outdoor rectangle is deliberately unpruned. Extending the prune to it would re-create #334 in a new form. - 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:
add_all_outside_cellspc:317343 rendersbaseXas((uint32_t)esi_4 - 1) >> 3. BN dropped theand eax, 0xffff(0x0053343a). Without itbaseXincludes the landblock bits and every delta is garbage.- pc:317330 renders the base-gid select as
((esi_2 - esi_2) & var_58), which is identically zero. The real code is the standardneg esi / sbb esi,esi / and esi,eaxconditional select (0x005333eb) =retval ? outsideCellId : 0. add_cell_blockpc:317219 rendersLScape::get_landcell(landscape, edx_2)withedx_2 = i & 7. The real argument isesi, the full computed cell id (0x00533230 push esi). Same artifact inadd_all_outside_cells(..., added_outside)whereadded_outside == 0).- Both
x87flag tests in the min/max accumulation appear asunimplemented {test ah, ...}/ bit-shuffledFCMP_UOexpressions. The real comparisons are plain integerjge/jleon_ftol2results (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
- 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.
- Whether
0x010046D8's box actually reaches0x87640011/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. - Whether
0x010046D8is 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. - Whether
CCellPortal::GetOtherCelltakesdo_not_load_cellsas a third argument. BN reads the field at0x0052cc2abut shows a two-argument call. Only matters for indoor static registration (§2.7); resolve by disassembly during S2 rather than porting BN's shape.