acdream/src/AcDream.Core/Physics/CellTransit.cs
Erik 13fcf38138 fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:07:06 +02:00

1443 lines
67 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
/// ported from retail's <c>CObjCell::find_cell_list</c> family
/// (sphere variant for the player's path spheres).
///
/// <para>
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
/// faithful point-in-cell tests via
/// <see cref="BSPQuery.PointInsideCellBsp"/>. Walks the portal graph
/// starting from a given current cell to find which cells a moving
/// sphere overlaps.
/// </para>
///
/// <para>
/// Reference pseudocode:
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
/// (2026-04-13). Retail decomp: <c>CEnvCell::find_transit_cells</c>
/// (sphere variant) at <c>acclient_2013_pseudo_c.txt</c>.
/// </para>
/// </summary>
public static class CellTransit
{
/// <summary>
/// Small radius padding matching retail's <c>EPSILON</c> usage in the
/// sphere-plane distance test (research doc §"EnvCell.find_transit_cells").
/// </summary>
private const float EPSILON = 0.02f;
/// <summary>
/// Retail <c>F_EPSILON</c> (acclient.exe data @ 007c8c70, 0.000199999995f) —
/// the pad added to the sphere radius in the exterior-portal straddle test
/// (<c>fadd [ecx+4]</c> at 0052c8eb, #112 rider live-binary read 2026-06-10).
/// </summary>
private const float FEpsilon = 0.000199999995f;
/// <summary>
/// Indoor portal-neighbour expansion. For each portal of
/// <paramref name="currentCell"/>, test whether the sphere overlaps
/// the portal polygon's plane in cell-local space. If so, add the
/// neighbour cell to <paramref name="candidates"/>.
///
/// <para>
/// Ported from <c>CEnvCell::find_transit_cells</c> (sphere variant)
/// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)".
/// </para>
/// </summary>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
ICollection<uint> candidates,
out bool exitOutside)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
FindTransitCellsSphere(
cache, currentCell, currentCellId,
spheres, spheres.Length, candidates, out exitOutside);
}
/// <summary>
/// Multi-sphere form used by retail's <c>CObjCell::find_cell_list</c>:
/// pass <c>sphere_path.num_sphere</c> and <c>sphere_path.global_sphere</c>.
/// Any sphere can trigger a portal neighbor or outdoor exit.
/// </summary>
/// <param name="exitOutside">RETAIL semantics (live-binary verified
/// 2026-06-10, #112 rider): true iff some path sphere STRADDLES one of this
/// cell's exterior portal polygon planes — <c>|dist| &lt; radius + EPSILON</c>.
/// This is the only condition under which retail's
/// <c>CEnvCell::find_transit_cells</c> calls <c>add_all_outside_cells</c>
/// (acclient.exe 0052c8e5-0052c92d straddle test, 0052c9d2-0052c9f0 gate;
/// pseudo-C :310070-310120). Drives the membership pick's outdoor branch
/// AND (BR-7 C4, retail-faithfully) the collision cell-set outside-add —
/// the former <c>hasExitPortal</c> topology widening is deleted.</param>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
ICollection<uint> candidates,
out bool exitOutside)
{
exitOutside = false;
uint lbPrefix = currentCellId & 0xFFFF0000u;
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return;
for (int portalIndex = 0;
portalIndex < currentCell.Portals.Count;
portalIndex++)
{
PortalInfo portal = currentCell.Portals[portalIndex];
if (!TryGetPortalPlane(
currentCell,
portalIndex,
portal,
out Plane portalPlane))
{
continue;
}
if (portal.OtherCellId == 0xFFFF)
{
// #112 rider (2026-06-10): retail straddle gate, RESTORED and
// verified against the LIVE 2013 binary (cdb attach, function
// 0052c820; x87 decode at 0052c8e5-0052c92d):
//
// pad = sphere.radius + F_EPSILON (fadd [ecx+4])
// dist = dot(localCenter, portalPlane.n) + d (cell-local)
// flag |= (dist > -pad) && (dist < +pad) (fcompp/test ah,41h
// + fcomp/test ah,5/jp)
//
// add_all_outside_cells fires IFF flag (0052c9d6 je → skip). No
// portal_side / exact_match in this branch — BN's pseudo-C
// invented those (feedback_bn_decomp_field_names).
//
// History: this gate existed pre-A6.P5, was removed 2026-05-25
// citing the CALLER (find_cell_list :308775-:308785 walks every
// array cell unconditionally — true, but each callee still
// applies its own straddle gate), and was restored for the
// membership PICK by the #112 rider. BR-7 / A6.P4 C4
// (2026-06-11) finished the story: the per-cell shadow
// architecture made the A6.P5 hasExitPortal topology widening
// unnecessary (doors are found in the straddle-admitted outdoor
// cell's own list), so this flag now gates BOTH the pick's
// outdoor branch AND the collision cell-set outside-add —
// pure retail.
if (!exitOutside)
{
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
float pad = sphere.Radius + FEpsilon;
var localCenter = Vector3.Transform(
sphere.Origin, currentCell.InverseWorldTransform);
float dist =
Vector3.Dot(localCenter, portalPlane.Normal) +
portalPlane.D;
if (dist > -pad && dist < pad)
{
exitOutside = true;
break;
}
}
}
continue;
}
uint otherId = lbPrefix | portal.OtherCellId;
// Retail CEnvCell::find_transit_cells first asks the loaded
// neighbour cell whether the sphere intersects its CellBSP.
// The portal-plane side test is only the unloaded-cell load hint.
RecordUnionOnlyProbe(candidates, otherId);
var otherCell = cache.GetCellStruct(otherId);
if (otherCell is not null &&
CollisionTraversal.HasCellContainment(cache, otherCell))
{
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
var otherLocalCenter = Vector3.Transform(
sphere.Origin, otherCell.InverseWorldTransform);
bool hit = CollisionTraversal.SphereIntersectsCell(
cache,
otherCell,
otherLocalCenter,
sphere.Radius);
if (hit)
{
candidates.Add(otherId);
break;
}
}
continue;
}
// Conservative unloaded-cell hint: the sphere is near the portal
// plane and on the outward side (per PortalSide).
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
float rad = sphere.Radius + EPSILON;
var localCenter = Vector3.Transform(
sphere.Origin, currentCell.InverseWorldTransform);
float dist =
Vector3.Dot(localCenter, portalPlane.Normal) +
portalPlane.D;
bool hit = portal.PortalSide ? dist > -rad : dist < rad;
if (hit)
{
candidates.Add(otherId);
break;
}
}
}
}
/// <summary>
/// Resolves the portal plane from whichever immutable representation owns
/// this cell. Graph fixtures retain the DAT polygon dictionary; production
/// cells intentionally retain only the prepared topology's direct polygon
/// index and flat polygon table.
/// </summary>
private static bool TryGetPortalPlane(
CellPhysics cell,
int portalIndex,
PortalInfo portal,
out Plane plane)
{
if (cell.PortalPolygons is not null &&
cell.PortalPolygons.TryGetValue(
portal.PolygonId,
out ResolvedPolygon? polygon))
{
plane = polygon.Plane;
return true;
}
FlatEnvCellTopology? topology = cell.FlatTopology;
FlatPolygonTable? polygonTable = cell.FlatPortalPolygons;
if (topology is null || polygonTable is null)
{
plane = default;
return false;
}
if ((uint)portalIndex >= (uint)topology.Portals.Length)
{
throw new InvalidDataException(
$"Cell 0x{cell.SourceId:X8} portal {portalIndex} is absent " +
"from its prepared topology.");
}
FlatEnvCellPortal flatPortal = topology.Portals[portalIndex];
if (flatPortal.OtherCellId != portal.OtherCellId ||
flatPortal.PolygonId != portal.PolygonId ||
flatPortal.Flags != portal.Flags)
{
throw new InvalidDataException(
$"Cell 0x{cell.SourceId:X8} portal {portalIndex} does not " +
"match its prepared topology.");
}
int polygonIndex = flatPortal.PolygonIndex;
if ((uint)polygonIndex >= (uint)polygonTable.Polygons.Length)
{
throw new InvalidDataException(
$"Cell 0x{cell.SourceId:X8} portal {portalIndex} references " +
$"invalid prepared polygon index {polygonIndex}.");
}
plane = polygonTable.Polygons[polygonIndex].Plane;
return true;
}
/// <summary>
/// Outdoor neighbour expansion. Ported from
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant,
/// pc:317499 @0x00533630) per
/// <c>docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md</c>.
///
/// <para>
/// Retail runs this in the GLOBAL landcell grid (<see cref="LandDefs"/>
/// lcoords, 0..2039 across the whole map): <c>adjust_to_outside</c> re-seats
/// the (cell, position) pair onto the landcell actually under the sphere —
/// crossing landblock boundaries when <c>floor(local/24)</c> leaves the
/// current block's 8×8 grid — and <c>check_add_cell_boundary</c> adds up to
/// 3 neighbour cells (strict &gt;/&lt; against the sphere radius), each id
/// re-derived from its own global lcoord. Issue #106: the pre-fix port
/// clamped everything to the current landblock's grid, so the candidate set
/// emptied the moment the player stepped over a boundary and membership
/// froze on the last in-block cell.
/// </para>
///
/// <para>
/// <paramref name="worldSphereCenter"/> is in the floating world frame
/// (anchor landblock at origin — the convention every physics caller uses);
/// <paramref name="currentBlockOrigin"/> is the current cell's landblock
/// world origin (SW corner; <see cref="World.Cells.CellGraph.TryGetTerrainOrigin"/>),
/// which converts it to retail's block-local frame. Pass
/// <see cref="Vector3.Zero"/> when the current block IS the anchor — the
/// pre-#106 behavior, and what the A6.P4 (2026-05-24) "landblock-local
/// coords" convention actually meant.
/// </para>
/// </summary>
/// <returns>
/// False when <c>adjust_to_outside</c> rejects the position (map edge /
/// invalid cell id) — retail breaks out of the sphere loop on that.
/// </returns>
public static bool AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
// Retail's position is block-local to the current cell's landblock.
var center = worldSphereCenter - currentBlockOrigin;
uint cellId = currentCellId;
if (!LandDefs.AdjustToOutside(ref cellId, ref center))
return false;
if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
return false;
AddOutsideCell(candidates, lx, ly);
// check_add_cell_boundary (pc:317229 @0x00533260): the point within the
// 24 m cell, from the adjust_to_outside-normalized block-local center
// (always [0, 192) post-adjust; floor-mod for safety). Strict >/< —
// a sphere exactly tangent to a boundary does NOT add the neighbour.
float pointX = center.X - MathF.Floor(center.X / LandDefs.CellLength) * LandDefs.CellLength;
float pointY = center.Y - MathF.Floor(center.Y / LandDefs.CellLength) * LandDefs.CellLength;
float minRad = sphereRadius;
float maxRad = LandDefs.CellLength - sphereRadius;
if (pointX > maxRad)
{
AddOutsideCell(candidates, lx + 1, ly);
if (pointY > maxRad) AddOutsideCell(candidates, lx + 1, ly + 1);
if (pointY < minRad) AddOutsideCell(candidates, lx + 1, ly - 1);
}
if (pointX < minRad)
{
AddOutsideCell(candidates, lx - 1, ly);
if (pointY > maxRad) AddOutsideCell(candidates, lx - 1, ly + 1);
if (pointY < minRad) AddOutsideCell(candidates, lx - 1, ly - 1);
}
if (pointY > maxRad) AddOutsideCell(candidates, lx, ly + 1);
if (pointY < minRad) AddOutsideCell(candidates, lx, ly - 1);
return true;
}
/// <summary>
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
/// path sphere and adds the outdoor landcells touched by any of them;
/// an <c>adjust_to_outside</c> failure BREAKS the loop (pc:533699).
/// </summary>
public static void AddAllOutsideCells(
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
if (!AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, currentBlockOrigin, candidates))
break;
}
}
/// <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,
// then lcoord_to_gid — NO same-block filter (ACE's add_cell_block
// "FIXME!" guard is an ACE divergence, not retail). The block id is
// re-derived from the global lcoord, so neighbour-landblock cells come
// out with the neighbour's prefix.
uint gid = LandDefs.LcoordToGid(lx, ly);
if (gid != 0u) candidates.Add(gid);
}
/// <summary>
/// Outdoor→indoor entry path. Ported from retail's
/// <c>BuildingObj::find_building_transit_cells</c> +
/// <c>EnvCell::check_building_transit</c>. For each portal of the
/// outdoor building, look up the destination interior cell and test
/// whether the sphere overlaps it via
/// <see cref="BSPQuery.SphereIntersectsCellBsp"/>. If so, add the
/// interior cell to <paramref name="candidates"/>.
///
/// <para>
/// Issue #89 closed (2026-05-20): uses retail's radius-aware
/// <c>CCellStruct::sphere_intersects_cell</c>
/// (<c>acclient_2013_pseudo_c.txt:317666</c>) ported as
/// <see cref="BSPQuery.SphereIntersectsCellBsp"/>. Promotes CellId to
/// the interior cell the moment ANY part of the foot-sphere crosses
/// the cell boundary — matches retail entry timing exactly and
/// closes the login-inside-inn classification race where the player
/// would briefly be classified outdoor and walk through walls.
/// </para>
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
Vector3 worldSphereCenter,
float sphereRadius,
ICollection<uint> candidates)
=> CheckBuildingTransit(
cache, building,
new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius } },
1, candidates, out _);
/// <summary>
/// Multi-sphere form matching retail's call shape: every path/flood
/// sphere is tested and the FIRST one intersecting the interior cell's
/// BSP admits the cell (<c>CEnvCell::check_building_transit</c>,
/// Ghidra 0x0052c5d0 — per-sphere loop at 0052c5fe, break-on-hit).
/// </summary>
/// <param name="hitsInteriorCell">True when at least one interior cell
/// was admitted — retail writes <c>SPHEREPATH.hits_interior_cell = 1</c>
/// at 0052c650 the moment a sphere lands a building-transit cell. Feeds
/// the building-shell <c>bldg_check</c> weakening in
/// <c>BSPTREE::find_collisions</c> (0x0053a440).</param>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
ICollection<uint> candidates,
out bool hitsInteriorCell)
{
hitsInteriorCell = false;
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return;
foreach (var portal in building.Portals)
{
// BR-7 / A6.P4 (2026-06-11): retail's first gate — the whole
// transit is rejected when other_portal_id is negative
// (`if (arg2 >= 0)` at 0x0052c5dc; arg2 is the SIGNED
// sign-extended CBldPortal.other_portal_id, acclient.h:32098).
// Wire 0xFFFF = -1 = "no reciprocal portal".
if (portal.OtherPortalId < 0)
continue;
RecordUnionOnlyProbe(candidates, portal.OtherCellId);
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell))
{
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
string reason = otherCell is null ? "cell not cached" : "CellBSP null";
Console.WriteLine(System.FormattableString.Invariant(
$"[check-bldg] portal->0x{portal.OtherCellId:X8} skipped: {reason}"));
}
continue;
}
// Sphere center in the OTHER cell's local space.
// Issue #89 closed (2026-05-20): use radius-aware sphere-overlap
// (matches retail's CCellStruct::sphere_intersects_cell at
// acclient_2013_pseudo_c.txt:317666) instead of point-only. This
// promotes the player's CellId to the interior cell the moment
// ANY part of the foot-sphere crosses the cell boundary — the
// entry-side counterpart to issue #90's sticky-stay fix. Without
// it, login-inside-the-inn keeps the player classified outdoor
// until they walk further in (sphere center crosses), letting
// them run through exterior walls on the way out.
bool inside = false;
for (int i = 0; i < sphereCount && !inside; i++)
{
var sphere = worldSpheres[i];
var localCenter = Vector3.Transform(sphere.Origin, otherCell.InverseWorldTransform);
inside = CollisionTraversal.SphereIntersectsCell(
cache,
otherCell,
localCenter,
sphere.Radius);
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[check-bldg] portal->0x{portal.OtherCellId:X8} sphere#{i} wpos=({sphere.Origin.X:F3},{sphere.Origin.Y:F3},{sphere.Origin.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) r={sphere.Radius:F3} inside={inside}"));
}
}
if (inside)
{
// Retail sets SPHEREPATH.hits_interior_cell the moment a
// building-transit sphere lands an interior cell (0052c650).
hitsInteriorCell = true;
candidates.Add(portal.OtherCellId);
}
}
}
/// <summary>
/// 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):
/// the seed + growing-array walk, WITHOUT the containing-cell pick
/// (registration passes a null out-cell).
///
/// <para>Shape, all Ghidra-verified (wf1-interior-collision.md):</para>
/// <list type="number">
/// <item>Seed: indoor id (low16 ≥ 0x100) → exactly that one cell
/// (0052b563), added EVEN IF unloaded (retail add_cell()s the id
/// with a null pointer); outdoor id → the block-crossing
/// <see cref="AddAllOutsideCells(IReadOnlyList{Sphere}, int, uint, Vector3, ICollection{uint})"/>
/// (0052b53f).</item>
/// <item>Growing-array walk (0052b576-0052b5ab), gated on the seed
/// being LOADED: each array cell's <c>find_transit_cells</c>
/// (vtable+0x80). Indoor cells →
/// <see cref="FindTransitCellsSphere(PhysicsDataCache, CellPhysics, uint, IReadOnlyList{Sphere}, int, ICollection{uint}, out bool, out bool)"/>
/// (sphere-vs-neighbor-BSP gates; exterior straddle → outside
/// cells, once per walk like retail's CELLARRAY.added_outside).
/// Outdoor cells → <c>CLandCell::find_transit_cells</c>
/// (0x00533800) = add_all_outside_cells (same once-guard) +
/// the building bridge <c>CSortCell → CBuildingObj →
/// CEnvCell::check_building_transit</c>
/// (0x00534060/0x006b5230/0x0052c5d0) — how an outdoor-seeded
/// door reaches the vestibule's shadow list. Unloaded indoor
/// cells in the array are not walked (0052b58e null check).</item>
/// <item>Static prune (<paramref name="isStatic"/>, retail
/// <c>do_not_load_cells</c>, 0052b66e): when the seed is indoor,
/// flood results are pruned to {seed} seed.stab_list
/// (<see cref="CellPhysics.VisibleCellIds"/>) — placement of
/// statics must not force-load cells. Note this also strips
/// outdoor cells (they are never in an EnvCell stab list):
/// retail interior statics never shadow into landcells; an
/// outdoor sphere reaches them via its OWN array's
/// check_building_transit instead.</item>
/// </list>
///
/// <para>Flood spheres: the object's REAL collision footprint — retail
/// globalizes the CylSpheres (low_pt → world, cyl radius, cap 10;
/// overload 0x0052b9f0), falling back to the part-array sorting sphere.
/// Callers map ShadowShape lists via
/// <see cref="ShadowShapeBuilder"/>-derived helpers (see
/// ShadowObjectRegistry).</para>
/// </summary>
public static IReadOnlyList<uint> BuildShadowCellSet(
PhysicsDataCache cache,
uint seedCellId,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
bool isStatic)
{
var candidates = new CellArray();
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (seedCellId == 0 || sphereCount == 0)
return candidates.OrderedIds;
uint seedLow = seedCellId & 0xFFFFu;
// #106 frame convention: LandDefs lcoord math runs block-local;
// TryGetTerrainOrigin supplies the seed block's world origin
// (Zero fallback = legacy anchor-frame, same as the transit path).
cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin);
bool seedLoaded;
if (seedLow >= 0x0100u)
{
candidates.Add(seedCellId);
// Retail adds the unloaded seed by id (null cell pointer) and
// skips the walk (gate on arg4 != 0 at 0052b576) — the object
// stays registered under just its claimed cell until the cell
// hydrates and the registration is re-run (CObjCell::init_objects
// → recalc_cross_cells, Ghidra 0x0052b420/0x00515a30; our
// equivalent is ShadowObjectRegistry's re-flood hook).
seedLoaded = cache.GetCellStruct(seedCellId) is not null;
}
else
{
AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
// Retail preserves the outside-cell additions above but skips
// the complete growing-array transit walk when GetVisible cannot
// resolve the ACTIVE seed CLandCell (0052b50e, 0052b576). A cached
// building alone must not promote an object through an unavailable
// landcell; the normal reflood after terrain publication retries.
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
}
if (seedLoaded)
{
bool outdoorAdded = seedLow < 0x0100u; // retail CELLARRAY.added_outside
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;
FindTransitCellsSphere(
cache, cell, cellId, worldSpheres, sphereCount,
candidates, out bool exitStraddle);
if (exitStraddle && !outdoorAdded)
{
AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
outdoorAdded = true;
}
}
else
{
// CELLARRAY stores GetVisible's result beside every id.
// Retail skips a later candidate whose cell pointer is
// null (0052b588..0052b59f), even when a stale building
// record for that landcell remains cached.
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// CLandCell::find_transit_cells (0x00533800):
// add_all_outside_cells (added_outside-guarded) then the
// building bridge for the landcell's building, if any.
if (!outdoorAdded)
{
AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
outdoorAdded = true;
}
var building = cache.GetBuilding(cellId);
if (building is not null)
CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _);
}
}
// Static prune (do_not_load_cells, 0052b66e): indoor-seeded
// statics keep only {seed} seed.stab_list.
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>
/// #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
/// <c>point_in_cell</c> contains <paramref name="worldPoint"/>, checking the
/// start cell first (:311402), then — when <paramref name="useStabList"/> is
/// true (retail <c>arg3 != 0</c>, :311444) — the start's <c>stab_list</c>
/// (<see cref="CellPhysics.VisibleCellIds"/>), else (<c>arg3 == 0</c>, :311411)
/// its direct portal neighbours. Returns 0 when no cell contains the point
/// (retail <c>return 0</c> at :311469).
///
/// <para>
/// Sibling of <see cref="FindCellList"/> (retail <c>find_cell_list</c>) — both
/// resolve membership from the cell graph via <see cref="BSPQuery.PointInsideCellBsp"/>.
/// Used by <c>CPhysicsObj::AdjustPosition</c> (pc:280028, <c>arg5 = 1</c> →
/// stab-list mode) to seat the camera sweep's start cell at the head-pivot.
/// </para>
///
/// <para>
/// A missing or rootless <see cref="CellPhysics"/> record is unavailable
/// and skipped. The retail inside base case belongs to a missing positive
/// child below a valid root, not to the root itself.
/// </para>
/// </summary>
public static uint FindVisibleChildCell(
PhysicsDataCache cache,
uint startCellId,
Vector3 worldPoint,
bool useStabList,
ICollection<uint>? probedCells = null)
{
probedCells?.Add(startCellId);
var start = cache.GetCellStruct(startCellId);
if (start is null) return 0u;
// this->point_in_cell(point) → return this (:311402-311405)
if (PointInCell(cache, start, worldPoint)) return startCellId;
if (useStabList)
{
// arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds)
{
probedCells?.Add(id);
if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id;
}
}
else
{
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals)
{
probedCells?.Add(portal.OtherCellId);
if (PointInCell(
cache,
cache.GetCellStruct(portal.OtherCellId),
worldPoint))
{
return portal.OtherCellId;
}
}
}
return 0u;
}
/// <summary>
/// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>.
/// A missing/rootless payload returns false. Retail also returns false
/// before containment when <c>CEnvCell::portals</c> is null.
/// </summary>
private static bool PointInCell(
PhysicsDataCache cache,
CellPhysics? cell,
Vector3 worldPoint)
{
if (cell is null ||
cell.Portals.Count == 0 ||
!CollisionTraversal.HasCellContainment(cache, cell))
{
return false;
}
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
return CollisionTraversal.PointInsideCell(cache, cell, local);
}
/// <summary>
/// Top-level cell-tracking driver, ported from retail's
/// <c>CObjCell::find_cell_list</c> (sphere variant).
///
/// <para>
/// Walks the portal graph from <paramref name="currentCellId"/>,
/// finds the cell whose <see cref="CellPhysics.CellBSP"/> contains
/// the sphere center, and returns its full id (landblock-prefixed).
/// Falls back to <paramref name="currentCellId"/> when no candidate
/// matches. The candidate set built internally is discarded; use
/// <see cref="FindCellSet"/> to recover it.
/// </para>
///
/// <para>
/// Pseudocode reference:
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
/// §"Overall Driver: find_cell_list".
/// </para>
/// </summary>
public static uint FindCellList(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId)
{
return FindCellSet(cache, worldSphereCenter, sphereRadius, currentCellId, out _);
}
/// <summary>
/// Phase A4 (2026-05-20). Same portal-graph traversal as
/// <see cref="FindCellList"/> but additionally returns the full
/// candidate set built during traversal. Used by
/// <see cref="Transition.CheckOtherCells"/> to iterate every cell
/// the sphere overlaps for per-cell BSP collision.
///
/// <para>
/// Retail oracle: <c>CTransition::check_other_cells</c> at
/// <c>acclient_2013_pseudo_c.txt:272717-272798</c> calls
/// <c>CObjCell::find_cell_list(&amp;this-&gt;cell_array, &amp;var_4c, ...)</c>
/// which fills both the cell_array (set) and var_4c (containing cell).
/// </para>
/// </summary>
public static uint FindCellSet(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out IReadOnlyCollection<uint> cellSet,
Vector3? carriedBlockOrigin = null)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet, carriedBlockOrigin);
}
/// <summary>
/// Multi-sphere form of <see cref="FindCellSet(PhysicsDataCache, Vector3, float, uint, out IReadOnlyCollection{uint})"/>.
/// Containment still uses sphere 0's center, matching retail's
/// <c>CObjCell::find_cell_list</c> loop after the transit set is built.
/// </summary>
public static uint FindCellSet(
PhysicsDataCache cache,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
out IReadOnlyCollection<uint> cellSet,
Vector3? carriedBlockOrigin = null)
{
var candidates = new CellArray();
var containing = BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId,
carriedBlockOrigin, candidates);
cellSet = candidates;
return containing;
}
internal static uint FindCellSet(
PhysicsDataCache cache,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
CellArray candidates,
Vector3? carriedBlockOrigin = null)
=> BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId,
carriedBlockOrigin, candidates);
private static uint BuildCellSetAndPickContaining(
PhysicsDataCache cache,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
Vector3? carriedBlockOrigin,
CellArray candidates)
{
// Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036).
// The ORDER is load-bearing: the current cell is added at index 0 and the
// pick iterates in order with interior-wins-break, so the current cell wins
// a boundary straddle and the membership does not ping-pong (the R1 flap).
candidates.Clear();
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return currentCellId;
Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu;
// #145: the carried cell-relative frame supplies the TRUE landblock world
// origin (body.Position - body.CellPosition.Frame.Origin) — correct even
// for an UNSTREAMED neighbour, where TryGetTerrainOrigin returns (0,0) and
// the pick marches the cell id one block per tick (the far-town cascade).
// Falls back to the terrain registry for unseeded movers (NPCs/tests) and
// indoor seeds. The caller guarantees the anchor's landblock == currentCellId's
// (PhysicsEngine passes body.CellPosition.ObjCellId as the cell when seeded).
//
// #106: blockOrigin converts the world-frame sphere coords into retail's
// block-local frame for the LandDefs lcoord math.
//
// #145 D (2026-06-22): honor TryGetTerrainOrigin's false return for OUTDOOR
// seeds. When the current landblock has not yet been applied (priority-apply
// in flight or streaming still warm), blockOrigin=(0,0) is wrong — the pick
// treats the world-frame sphere coordinates as if they were block-local and
// marches the cell one block per tick until lbX or lbY underflows to 0x00
// (the "lbX=0" outbound wire that ACE rejects). "frame not yet authoritative
// → preserve verbatim" is the same no-landblock-match invariant canonical
// PhysicsEngine.SetPosition's AdjustSetPosition/DeferredCell path holds today
// (C5a, 2026-08-05: cite by symbol — the legacy PhysicsEngine.Resolve this
// note used to name is deleted, zero production callers).
// Indoor seeds are NOT guarded here because blockOrigin is only consumed by the
// outdoor pick path (outdoorPickAllowed=false for indoor seeds); returning early
// for indoor seeds would break all interior cell-set builds (regression).
// Adaptation (not a direct retail port — retail gets it free via cell-relative
// storage); registered as divergence-register row added in this commit.
Vector3 blockOrigin;
if (carriedBlockOrigin is { } carriedAnchor)
{
blockOrigin = carriedAnchor;
}
else
{
bool terrainResident = cache.CellGraph.TryGetTerrainOrigin(currentCellId, out blockOrigin);
// Outdoor seed with no resident terrain: no valid block-local frame → preserve seed verbatim.
// Indoor seeds proceed regardless (blockOrigin unused for indoor picks).
if (!terrainResident && currentLow < 0x0100u)
return currentCellId;
}
// #112 rider: outdoor candidates may win the pick only when retail would
// have admitted them — outdoor seeds always; indoor seeds only when a
// sphere straddled an exterior portal plane during the BFS (set below).
bool outdoorPickAllowed = currentLow < 0x0100u;
// SEED (retail CObjCell::find_cell_list 0052b535-0052b56c): an indoor id
// adds exactly the current cell at INDEX 0 (the current-cell-first pick
// hysteresis that stops the flap); an outdoor id adds every landcell the
// path spheres overlap (add_all_outside_cells, 0052b53f — which also
// sets CELLARRAY.added_outside, hence outdoorAdded starts true there).
bool outdoorAdded;
if (currentLow >= 0x0100u)
{
var currentCell = cache.GetCellStruct(currentCellId);
if (currentCell is null) return currentCellId;
candidates.Add(currentCellId);
outdoorAdded = false;
}
else
{
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
outdoorAdded = true;
}
// THE WALK — ONE forward pass over the GROWING array for EVERY seed,
// mirroring retail's `for (i=0; i<num_cells; i++)
// cells[i]->find_transit_cells(...)` vtable dispatch (pseudo_c:
// 308775-308785 / 0052b576-0052b5ab). CellArray.Add dedups, so the walk
// terminates when no new cell is appended; read OrderedIds[i] by index
// because the list grows under us.
//
// #112 ROOT CAUSE (2026-06-12, cottage-112-capture1.log): the outdoor
// seed used to run CheckBuildingTransit over a landcell SNAPSHOT and
// stop — building-admitted entry cells were never expanded, so a player
// whose centre stood in a DEEP room (not building-portal-adjacent)
// could never be promoted from an outdoor seed: the pick kept the
// outdoor landcell while they walked the cottage interior (transparent
// interior; promotion fired only on touching portal-adjacent 0x102's
// own volume). Retail's single growing walk expands the admitted entry
// cells to the deeper rooms the spheres overlap — ported below.
for (int i = 0; i < candidates.Count; i++)
{
uint cellId = candidates.OrderedIds[i];
if ((cellId & 0xFFFFu) < 0x0100u)
{
// Match CELLARRAY's stored GetVisible pointer: an adjacent
// landcell id may be present because the sphere overlaps it,
// while that landblock is not loaded yet.
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// Landcell dispatch — CLandCell::find_transit_cells (0x00533800)
// → CSortCell::find_transit_cells (0x00534060, this->building)
// → CBuildingObj::find_building_transit_cells (0x006b5230)
// → CEnvCell::check_building_transit (0x0052c5d0): the building
// bridge admits the building's portal-adjacent ENTRY cells into
// the same growing array; the walk then expands them via the
// envcell dispatch below.
var building = cache.GetBuilding(cellId);
if (building is null) continue;
CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _);
continue;
}
var cell = cache.GetCellStruct(cellId);
if (cell is null) continue;
FindTransitCellsSphere(
cache, cell, cellId, worldSpheres, sphereCount,
candidates, out bool exitOutsideStraddle);
// #112 rider (2026-06-10): the retail straddle flag (live-binary
// verified — see FindTransitCellsSphere) gates the PICK's outdoor
// branch below. Retail only ever has outdoor cells in this array
// when a path sphere straddles an exterior portal plane.
outdoorPickAllowed |= exitOutsideStraddle;
// BR-7 / A6.P4 C4 (2026-06-11): outdoor cells enter the array
// on the retail STRADDLE gate — |dist| < radius + F_EPSILON
// against an exterior portal plane (CEnvCell::find_transit_cells
// 0x0052c820; gate at 0052c9d6) — replacing the A6.P5
// hasExitPortal TOPOLOGY widening. Appended AFTER the interior
// cells, matching retail order (add_all_outside_cells at the end,
// pseudo_c:310120) — interior-wins is preserved. Once-per-walk via
// outdoorAdded = retail CELLARRAY.added_outside (0x00533630).
if (exitOutsideStraddle && !outdoorAdded)
{
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
outdoorAdded = true;
}
}
if (PhysicsDiagnostics.ProbeCellSetEnabled)
PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates);
// THE PICK — verbatim CObjCell::find_cell_list containing-cell pick
// (pseudo_c:308788-308825): iterate the array IN ORDER from index 0; for each
// cell, point_in_cell; set the running result on ANY containing cell;
// INTERIOR-WINS-BREAK. The current cell is at index 0, so if the sphere centre
// is still inside it, it wins and the search stops — the retail hysteresis.
// (Replaces the 5ca2f44 current-first pre-check, which approximated this for
// the indoor-current case only; the ordered array now delivers it for every
// seed by construction.)
//
// #106: the outdoor containing cell is the GLOBAL XY-column under the sphere
// centre (LandDefs.AdjustToOutside from the current block's frame — retail
// subtracts get_block_offset per candidate before point_in_cell, pc:308804;
// landcells are disjoint columns so identity-compare is equivalent). The
// pre-fix [0,8)-clamped, current-prefix-only computation could never match a
// neighbour-block cell, freezing membership at landblock boundaries.
uint containingOutdoorId = 0u;
{
var pickPos = worldSphereCenter - blockOrigin;
uint pickCell = currentCellId;
if (LandDefs.AdjustToOutside(ref pickCell, ref pickPos))
containingOutdoorId = pickCell;
}
uint outdoorResult = 0u;
foreach (uint candId in candidates.OrderedIds)
{
if ((candId & 0xFFFFu) >= 0x0100u)
{
// Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
var cand = cache.GetCellStruct(candId);
if (PointInCell(cache, cand, worldSphereCenter))
return candId; // interior-wins, stop (pseudo_c:308819)
}
else if (outdoorResult == 0u &&
containingOutdoorId != 0u &&
outdoorPickAllowed &&
cache.CellGraph.GetVisible(candId) is not null)
{
// Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the
// documented adaptation). Record as the running result but DO NOT
// break — an interior cell later in the array can still win.
// #112 rider: gated on outdoorPickAllowed — retail's array only
// contains outdoor cells when a sphere straddled an exterior portal
// plane (live-binary verified); ours may also contain them via the
// A6.P5 collision widening, which the pick must ignore.
if (candId == containingOutdoorId)
outdoorResult = candId;
}
}
// No interior cell contained the centre. Return the outdoor XY-column cell if
// it was a candidate, else stay on the current cell (retail leaves *result
// null → caller keeps curr_cell).
if (outdoorResult != 0u) return outdoorResult;
// ── No containing cell: lateral recovery, then retail keep-curr ────
// Retail find_cell_list leaves *result null here and the CALLER KEEPS
// curr_cell (pc:308788-308825) — including when the centre sits in a
// containment GAP between a house's cell volumes. #112 (2026-06-10):
// the A9B3 hill cottage has a real gap inside the house; the 6dbbf95
// escape hatch that used to live here demoted such gaps to the
// outdoor column, stranding the player outdoor-classified deep inside
// the house (outdoor→indoor promotion is portal-adjacent-only, retail-
// identical) → the outdoor flood rendered the interior transparent.
// The hatch's actual target — poisoned (cell, position) SAVES — was
// handled at the SNAP by PhysicsEngine.AdjustPosition validation since
// #107/#111 (C5a, 2026-08-05: cite by symbol — the legacy
// PhysicsEngine.Resolve player-snap caller this note used to name is
// deleted, zero production callers; AdjustPosition survives, now
// reached in production only from PhysicsCameraCollisionProbe's
// camera-collision cell resolve, and canonical PhysicsEngine.SetPosition's
// AdjustSetPosition performs the equivalent validation for live
// placement); mid-session farness cannot arise (the
// sphere moves continuously, and real building exits flow through
// exterior portals → outside cells enter the candidate array → the
// normal outdoorResult path above demotes there, retail-faithfully).
//
// Before keeping a claim whose volume the sphere no longer overlaps,
// try the claim's VISIBLE GRAPH for a containing cell (retail
// CEnvCell::find_visible_child_cell in stab-list mode :311444 — the
// same recovery AdjustPosition uses at :280028): a near-miss claim
// one room off self-heals laterally instead of waiting for a doorway.
if (currentLow >= 0x0100u)
{
var cur = cache.GetCellStruct(currentCellId);
if (cur is not null &&
CollisionTraversal.HasCellContainment(cache, cur))
{
var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform);
if (!CollisionTraversal.SphereIntersectsCell(
cache,
cur,
curLocal,
sphereRadius))
{
uint recovered = FindVisibleChildCell(
cache,
currentCellId,
worldSphereCenter,
useStabList: true,
(candidates as CellArray)?.UnionTarget);
if (recovered != 0u && recovered != currentCellId)
return recovered;
}
}
}
return currentCellId;
}
private static void RecordUnionOnlyProbe(
ICollection<uint> candidates,
uint cellId)
{
if (candidates is CellArray { UnionTarget: { } queryFootprint })
queryFootprint.Add(cellId);
}
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
}
}