fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)

acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 19:07:06 +02:00
parent f0588725cf
commit 13fcf38138
19 changed files with 2186 additions and 137 deletions

View file

@ -372,6 +372,146 @@ public static class CellTransit
}
}
/// <summary>
/// Outdoor extent walk for a physics-BSP part array — the OTHER outdoor
/// expansion retail has, and the one #334 was missing entirely. Verbatim
/// port of <c>CLandCell::add_all_outside_cells</c> @0x00533360 (pc:317289)
/// plus <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202),
/// disassembled from the PDB-paired 2013-09-06 binary rather than read
/// from Binary Ninja's pseudo-C, which mis-renders four separate
/// constructs inside this one function
/// (<c>docs/research/2026-08-06-334-contract.md</c> §9.4).
///
/// <para>
/// Shape, byte-verified:
/// </para>
/// <list type="number">
/// <item>The base landcell comes from the FIRST part's own
/// <c>adjust_to_outside</c> (<c>0x005333a2</c>-<c>0x005333dd</c>),
/// NOT from the object's position; a failed adjust selects gid 0
/// through the <c>neg/sbb/and</c> conditional select at
/// <c>0x005333eb</c>, whose <c>get_landcell</c> then returns null
/// and the walk returns (<c>0x00533417</c>).</item>
/// <item><c>baseX = ((gid &amp; 0xFFFF) - 1) >> 3</c>
/// (<c>0x0053343a and eax,0xffff</c> — the mask BN drops) and
/// <c>baseY = (gid - 1) &amp; 7</c> (<c>0x00533443</c>): WITHIN-BLOCK
/// 0..7, bridged to the GLOBAL lcoords from
/// <c>gid_to_lcoord</c> (<c>0x00533428</c>) by the four deltas.</item>
/// <item>Per part: <c>GetBoundingBox</c> @0x0050d600 →
/// <c>BBox::LocalToGlobal</c> @0x005b2120 (<c>0x00533527</c>), then
/// <c>floor(v / square_length)</c> on min.x, min.y, max.x, max.y
/// (stack slots <c>+0x48/+0x4c/+0x54/+0x58</c> in the entry frame —
/// the raw displacements differ only because <c>sub esp,8</c> at
/// <c>0x00533536</c> brackets the middle three). Z is never read:
/// land cells are a 2-D grid. <c>square_length</c> is
/// <c>0x7c920c</c> = <c>00 00 c0 41</c> = 24.0f, read from the
/// binary.</item>
/// <item>The four accumulators are seeded to ZERO
/// (<c>0x00533390</c>-<c>0x0053339c</c>), so the rectangle always
/// contains the base cell, and are combined with plain integer
/// <c>jge</c>/<c>jle</c> (<c>0x005335a6</c>, <c>0x005335b8</c>,
/// <c>0x005335c8</c>, <c>0x005335d9</c>) — BN renders these as
/// <c>unimplemented {test ah}</c> / <c>FCMP_UO</c>.</item>
/// <item>ONE rectangle over ALL parts, filled — not outlined, not a
/// per-part union (<c>0x00533614</c>
/// <c>add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY,
/// cellarray)</c>, argument order recovered from the five pushes
/// at <c>0x005335f2</c>-<c>0x00533613</c>). An L-shaped object
/// claims the notch; retail's coverage is deliberately
/// conservative and lets the narrow phase reject.</item>
/// </list>
///
/// <para>
/// Retail also has an <c>Always2D()</c> arm (<c>0x0053346b</c>) that falls
/// back to the part's sphere. It is unreachable here: this overload is
/// only ever handed physics-BSP parts, and a 2-D sprite part carries no
/// physics BSP.
/// </para>
/// </summary>
/// <param name="worldParts">The object's physics-BSP parts, world-placed.</param>
/// <param name="currentCellId">The flood seed cell — supplies the landblock
/// base <c>adjust_to_outside</c> measures part 0's position against.</param>
/// <param name="currentBlockOrigin">World origin of the seed cell's
/// landblock (#106 frame convention); <see cref="Vector3.Zero"/> when the
/// seed block IS the anchor.</param>
/// <returns>False when <c>adjust_to_outside</c> or <c>gid_to_lcoord</c>
/// rejects the base position (map edge / invalid id) — retail returns
/// without adding anything.</returns>
public static bool AddAllOutsideCellsFromParts(
IReadOnlyList<ShadowPartBox> worldParts,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
if (worldParts is null || worldParts.Count == 0)
return false;
// 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell.
// DeriveOutdoorSeed clamps its own result to the seed block; this
// deliberately does not inherit that clamp — a part array whose first
// part sits over the neighbour block anchors there, as retail does.
Vector3 seedFramePos = worldParts[0].WorldPosition - currentBlockOrigin;
Vector3 baseFramePos = seedFramePos;
uint baseCellId = currentCellId;
if (!LandDefs.AdjustToOutside(ref baseCellId, ref baseFramePos))
return false; // gid 0 → get_landcell null → return
if (!LandDefs.GidToLcoord(baseCellId, out int gx, out int gy))
return false; // 0x00533432 je 0x53361c
int baseX = (int)(((baseCellId & 0xFFFFu) - 1u) >> 3); // 0x0053343a
int baseY = (int)((baseCellId - 1u) & 7u); // 0x00533443
// adjust_to_outside re-based part 0's position into the ADJUSTED
// block's local frame; retail's BBox::LocalToGlobal writes every part
// box into that same frame (cell0->pos). The re-basing is a pure
// translation, so applying it to the world origin is exact.
Vector3 frameOrigin =
currentBlockOrigin - (baseFramePos - seedFramePos);
int minDX = 0, minDY = 0, maxDX = 0, maxDY = 0; // 0x00533390
for (int i = 0; i < worldParts.Count; i++)
{
worldParts[i].RefitTo(frameOrigin, out Vector3 boxMin, out Vector3 boxMax);
// floor, then _ftol2 — NOT truncation. C#'s (int)(v / 24f)
// truncates toward zero and is wrong for every negative
// block-local coordinate, which is precisely the case a part
// hanging off the block's SW corner produces.
int a = (int)MathF.Floor(boxMin.X / LandDefs.CellLength);
int b = (int)MathF.Floor(boxMin.Y / LandDefs.CellLength);
int c = (int)MathF.Floor(boxMax.X / LandDefs.CellLength);
int d = (int)MathF.Floor(boxMax.Y / LandDefs.CellLength);
if (a - baseX < minDX) minDX = a - baseX; // 0x005335a2
if (b - baseY < minDY) minDY = b - baseY; // 0x005335b4
if (c - baseX > maxDX) maxDX = c - baseX; // 0x005335c2
if (d - baseY > maxDY) maxDY = d - baseY; // 0x005335d5
}
AddCellBlock(gx + minDX, gy + minDY, gx + maxDX, gy + maxDY, candidates);
return true;
}
/// <summary>
/// <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202): both loops are
/// INCLUSIVE (<c>0x0053324d</c> / <c>0x00533246 jle</c>) and the rectangle
/// is FILLED. Coordinates are GLOBAL lcoords, so the landblock prefix is
/// re-derived per cell and the rectangle crosses landblock boundaries
/// freely — that re-derivation is <see cref="AddOutsideCell"/>'s
/// <see cref="LandDefs.LcoordToGid"/>, whose
/// <see cref="LandDefs.InBounds"/> rejection IS retail's
/// <c>0 &lt;= v &lt; 0x7f8</c> clamp at <c>0x005331f0</c>-<c>0x00533206</c>.
/// </summary>
private static void AddCellBlock(
int x0, int y0, int x1, int y1,
ICollection<uint> candidates)
{
for (int x = x0; x <= x1; x++)
for (int y = y0; y <= y1; y++)
AddOutsideCell(candidates, x, y);
}
private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
{
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
@ -500,8 +640,13 @@ public static class CellTransit
}
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an
/// object with NO physics BSP — the sphere-overlap portal flood retail
/// runs at SHADOW REGISTRATION time for the cylsphere and sorting-sphere
/// branches. It is ONE of TWO registration floods: a BSP-bearing object
/// takes <see cref="BuildShadowCellSetFromParts"/> instead
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230 dispatches on
/// <c>HAS_PHYSICS_BSP_PS</c> at <c>0x00515285</c>).
/// Verbatim port of <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0,
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
@ -657,6 +802,184 @@ public static class CellTransit
return candidates.OrderedIds;
}
/// <summary>
/// #334 (2026-08-06). Registration-side cell-set builder for a
/// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which
/// acdream had never implemented. Port of
/// <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 (pc:279006), the
/// branch <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 takes at
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c> — <c>HAS_PHYSICS_BSP_PS</c>
/// (<c>acclient.h:2833</c>). <see cref="BuildShadowCellSet"/> ports the
/// branches BELOW that jump (cylspheres, then the sorting sphere) and
/// remains correct for them.
///
/// <para>
/// <c>find_bbox_cell_list</c> forms no bounding box itself — it is a
/// worklist. It seeds the array with the object's OWN cell
/// (<c>0x00510fe2 CELLARRAY::add_cell</c>) and walks it while it grows,
/// re-reading <c>num_cells</c> each iteration
/// (<c>0x00510ff8</c> / <c>0x00511017</c> / <c>0x0051101d jb</c>),
/// dispatching each array cell through
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 — a forwarding
/// thunk to <c>cell-&gt;vtable[0x7c]</c> (<c>0x00518176</c>;
/// <c>CObjCell</c>'s vftable base <c>0x007c8b20</c> + <c>0x7c</c> =
/// <c>0x007c8b9c</c>, holding <c>0x0052b080</c>, the four-argument
/// part-array <c>find_transit_cells</c>). The boxes are formed one and two
/// levels down: outdoors in
/// <see cref="AddAllOutsideCellsFromParts"/>
/// (<c>CLandCell::find_transit_cells</c> @0x00533840 =
/// <c>add_all_outside_cells</c> @0x00533360 + the <c>CSortCell</c>
/// @0x00534080 building bridge), indoors in
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0.
/// </para>
///
/// <para>
/// Note the difference from <see cref="BuildShadowCellSet"/>'s seed: the
/// sphere overload calls <c>add_all_outside_cells</c> AT SEED TIME for an
/// outdoor id (<c>CObjCell::find_cell_list</c> <c>0x0052b53f</c>);
/// <c>find_bbox_cell_list</c> does not — the outdoor expansion happens
/// only when the walk reaches a landcell, under the same once-per-flood
/// <c>CELLARRAY::added_outside</c> latch (<c>0x0053336c</c>). And it is
/// ONE rectangle over all parts, run ONCE, not a per-part loop.
/// </para>
///
/// <para>
/// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array
/// overload — box-vs-portal-plane
/// (<c>BBox::LocalToLocal</c> @0x005b1e60 + <c>Plane::intersect_box</c>
/// @0x005aa170 at <c>0x0052cbf9</c>/<c>0x0052cc05</c>) and
/// <c>CCellStruct::box_intersects_cell</c> @0x00533910 — is NOT ported
/// here. Indoor candidates keep the sphere-vs-portal traversal
/// <see cref="FindTransitCellsSphere"/> already runs, from the same
/// per-part BSP root spheres, which is byte-for-byte the behaviour every
/// BSP object had before #334. AP-156's row already names that port as its
/// open residual; #334 is the OUTDOOR half of it.
/// </para>
/// </summary>
/// <param name="worldParts">Per-part world-placed authored boxes — the
/// outdoor extent walk's input.</param>
/// <param name="worldPartSpheres">Per-part world-placed BSP root spheres —
/// the indoor residual's and the building bridge's input. Same parts, same
/// order, from the same <see cref="ShadowPartGeometry"/> values.</param>
public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
PhysicsDataCache cache,
uint seedCellId,
IReadOnlyList<ShadowPartBox> worldParts,
IReadOnlyList<Sphere> worldPartSpheres,
bool isStatic)
{
var candidates = new CellArray();
if (seedCellId == 0u || worldParts is null || worldParts.Count == 0)
return candidates.OrderedIds;
int sphereCount =
EffectiveSphereCount(worldPartSpheres, worldPartSpheres?.Count ?? 0);
uint seedLow = seedCellId & 0xFFFFu;
cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin);
// SEED. 0x00510fd5-0x00510fe2 adds the object's own cell by id;
// 0x00510fed / 0x00510ff6 then skip the walk when the cell or the part
// array is null.
//
// DEVIATION (registered, AD-40): the outdoor rectangle runs at seed
// time here, not only from the walk. Retail can gate everything on
// obj->cell because a placed CPhysicsObj always has a resident
// CObjCell; acdream's CellGraph residency is transiently false during
// streaming (#168 / #169), and deferring the rectangle to the walk
// would drop a static or a live entity to a single cell for the window
// before its landblock publishes. This is the SAME residency policy
// BuildShadowCellSet already applies to its outdoor seed
// (CObjCell::find_cell_list 0x0052b53f, ahead of the arg4 walk gate),
// so the two registration floods differ only in sphere-vs-box — which
// is the whole of #334 — and the direction is over-inclusive.
bool outdoorAdded = false; // CELLARRAY::added_outside
bool seedLoaded;
if (seedLow >= 0x0100u)
{
candidates.Add(seedCellId);
seedLoaded = cache.GetCellStruct(seedCellId) is not null;
}
else
{
candidates.Add(seedCellId);
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
}
if (!seedLoaded)
return candidates.OrderedIds;
for (int i = 0; i < candidates.Count; i++)
{
uint cellId = candidates.OrderedIds[i];
if ((cellId & 0xFFFFu) >= 0x0100u)
{
var cell = cache.GetCellStruct(cellId);
if (cell is null) continue; // 0x00511009 null cell pointer
if (sphereCount == 0) continue;
FindTransitCellsSphere(
cache, cell, cellId, worldPartSpheres!, sphereCount,
candidates, out bool exitStraddle);
if (exitStraddle && !outdoorAdded)
{
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
}
}
else
{
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// CLandCell::find_transit_cells @0x00533840:
// add_all_outside_cells (added_outside-guarded) then the
// CSortCell building bridge for this landcell's building.
if (!outdoorAdded)
{
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
}
var building = cache.GetBuilding(cellId);
if (building is not null && sphereCount > 0)
{
CheckBuildingTransit(
cache, building, worldPartSpheres!, sphereCount,
candidates, out _);
}
}
}
// Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY.
// The outdoor rectangle is deliberately unpruned: pruning it would
// re-create #334 in a new form.
if (isStatic && seedLow >= 0x0100u)
{
var seedCell = cache.GetCellStruct(seedCellId);
if (seedCell is not null)
{
var keep = new List<uint>(candidates.Count);
foreach (uint id in candidates.OrderedIds)
{
if (id == seedCellId || seedCell.VisibleCellIds.Contains(id))
keep.Add(id);
}
if (keep.Count != candidates.Count)
{
candidates.Clear();
foreach (uint id in keep) candidates.Add(id);
}
}
}
return candidates.OrderedIds;
}
/// <summary>
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP