using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
///
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
/// ported from retail's CObjCell::find_cell_list family
/// (sphere variant for the player's path spheres).
///
///
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
/// faithful point-in-cell tests via
/// . Walks the portal graph
/// starting from a given current cell to find which cells a moving
/// sphere overlaps.
///
///
///
/// Reference pseudocode:
/// docs/research/acclient_indoor_transitions_pseudocode.md
/// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells
/// (sphere variant) at acclient_2013_pseudo_c.txt.
///
///
public static class CellTransit
{
///
/// Small radius padding matching retail's EPSILON usage in the
/// sphere-plane distance test (research doc §"EnvCell.find_transit_cells").
///
private const float EPSILON = 0.02f;
///
/// Retail F_EPSILON (acclient.exe data @ 007c8c70, 0.000199999995f) —
/// the pad added to the sphere radius in the exterior-portal straddle test
/// (fadd [ecx+4] at 0052c8eb, #112 rider live-binary read 2026-06-10).
///
private const float FEpsilon = 0.000199999995f;
///
/// Indoor portal-neighbour expansion. For each portal of
/// , test whether the sphere overlaps
/// the portal polygon's plane in cell-local space. If so, add the
/// neighbour cell to .
///
///
/// Ported from CEnvCell::find_transit_cells (sphere variant)
/// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)".
///
///
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
ICollection candidates,
out bool exitOutside)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
FindTransitCellsSphere(
cache, currentCell, currentCellId,
spheres, spheres.Length, candidates, out exitOutside);
}
///
/// Multi-sphere form used by retail's CObjCell::find_cell_list:
/// pass sphere_path.num_sphere and sphere_path.global_sphere.
/// Any sphere can trigger a portal neighbor or outdoor exit.
///
/// 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 — |dist| < radius + EPSILON.
/// This is the only condition under which retail's
/// CEnvCell::find_transit_cells calls add_all_outside_cells
/// (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 hasExitPortal topology widening is deleted.
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
IReadOnlyList worldSpheres,
int numSpheres,
ICollection candidates,
out bool exitOutside)
{
exitOutside = false;
uint lbPrefix = currentCellId & 0xFFFF0000u;
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (currentCell.PortalPolygons is null || sphereCount == 0) return;
foreach (var portal in currentCell.Portals)
{
if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
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, poly.Plane.Normal) + poly.Plane.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.
var otherCell = cache.GetCellStruct(otherId);
if (otherCell?.CellBSP?.Root is not null)
{
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
var otherLocalCenter = Vector3.Transform(
sphere.Origin, otherCell.InverseWorldTransform);
bool hit = BSPQuery.SphereIntersectsCellBsp(
otherCell.CellBSP.Root, 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, poly.Plane.Normal) + poly.Plane.D;
bool hit = portal.PortalSide ? dist > -rad : dist < rad;
if (hit)
{
candidates.Add(otherId);
break;
}
}
}
}
///
/// Outdoor neighbour expansion. Ported from
/// CLandCell::add_all_outside_cells (sphere variant,
/// pc:317499 @0x00533630) per
/// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
///
///
/// Retail runs this in the GLOBAL landcell grid (
/// lcoords, 0..2039 across the whole map): adjust_to_outside re-seats
/// the (cell, position) pair onto the landcell actually under the sphere —
/// crossing landblock boundaries when floor(local/24) leaves the
/// current block's 8×8 grid — and check_add_cell_boundary adds up to
/// 3 neighbour cells (strict >/< 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.
///
///
///
/// is in the floating world frame
/// (anchor landblock at origin — the convention every physics caller uses);
/// is the current cell's landblock
/// world origin (SW corner; ),
/// which converts it to retail's block-local frame. Pass
/// 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.
///
///
///
/// False when adjust_to_outside rejects the position (map edge /
/// invalid cell id) — retail breaks out of the sphere loop on that.
///
public static bool AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection 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;
}
///
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
/// path sphere and adds the outdoor landcells touched by any of them;
/// an adjust_to_outside failure BREAKS the loop (pc:533699).
///
public static void AddAllOutsideCells(
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection 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;
}
}
private static void AddOutsideCell(ICollection 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);
}
///
/// Outdoor→indoor entry path. Ported from retail's
/// BuildingObj::find_building_transit_cells +
/// EnvCell::check_building_transit. For each portal of the
/// outdoor building, look up the destination interior cell and test
/// whether the sphere overlaps it via
/// . If so, add the
/// interior cell to .
///
///
/// Issue #89 closed (2026-05-20): uses retail's radius-aware
/// CCellStruct::sphere_intersects_cell
/// (acclient_2013_pseudo_c.txt:317666) ported as
/// . 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.
///
///
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
Vector3 worldSphereCenter,
float sphereRadius,
ICollection candidates)
=> CheckBuildingTransit(
cache, building,
new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius } },
1, candidates, out _);
///
/// 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 (CEnvCell::check_building_transit,
/// Ghidra 0x0052c5d0 — per-sphere loop at 0052c5fe, break-on-hit).
///
/// True when at least one interior cell
/// was admitted — retail writes SPHEREPATH.hits_interior_cell = 1
/// at 0052c650 the moment a sphere lands a building-transit cell. Feeds
/// the building-shell bldg_check weakening in
/// BSPTREE::find_collisions (0x0053a440).
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
IReadOnlyList worldSpheres,
int numSpheres,
ICollection 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;
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell?.CellBSP?.Root is null)
{
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 = BSPQuery.SphereIntersectsCellBsp(
otherCell.CellBSP.Root, 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);
}
}
}
///
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
/// Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0,
/// pc:308742) as invoked by CPhysicsObj::calc_cross_cells /
/// calc_cross_cells_static (Ghidra 0x00515230 / 0x00515160):
/// the seed + growing-array walk, WITHOUT the containing-cell pick
/// (registration passes a null out-cell).
///
/// Shape, all Ghidra-verified (wf1-interior-collision.md):
///
/// - 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
///
/// (0052b53f).
/// - Growing-array walk (0052b576-0052b5ab), gated on the seed
/// being LOADED: each array cell's find_transit_cells
/// (vtable+0x80). Indoor cells →
///
/// (sphere-vs-neighbor-BSP gates; exterior straddle → outside
/// cells, once per walk like retail's CELLARRAY.added_outside).
/// Outdoor cells → CLandCell::find_transit_cells
/// (0x00533800) = add_all_outside_cells (same once-guard) +
/// the building bridge CSortCell → CBuildingObj →
/// CEnvCell::check_building_transit
/// (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).
/// - Static prune (, retail
/// do_not_load_cells, 0052b66e): when the seed is indoor,
/// flood results are pruned to {seed} ∪ seed.stab_list
/// () — 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.
///
///
/// 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
/// -derived helpers (see
/// ShadowObjectRegistry).
///
public static IReadOnlyList BuildShadowCellSet(
PhysicsDataCache cache,
uint seedCellId,
IReadOnlyList 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);
// Outdoor seeds always walk: retail's null-CLandCell case is
// "landblock not loaded at all", where our per-cell building
// lookups below come back null anyway (documented adaptation).
seedLoaded = true;
}
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
{
// 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(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;
}
///
/// Verbatim port of CEnvCell::find_visible_child_cell
/// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP
/// point_in_cell contains , checking the
/// start cell first (:311402), then — when is
/// true (retail arg3 != 0, :311444) — the start's stab_list
/// (), else (arg3 == 0, :311411)
/// its direct portal neighbours. Returns 0 when no cell contains the point
/// (retail return 0 at :311469).
///
///
/// Sibling of (retail find_cell_list) — both
/// resolve membership from the cell graph via .
/// Used by CPhysicsObj::AdjustPosition (pc:280028, arg5 = 1 →
/// stab-list mode) to seat the camera sweep's start cell at the head-pivot.
///
///
///
/// acdream adaptation (matches at line 518): a cell
/// with no hydrated cannot run
/// point_in_cell, so it is treated as NOT containing the point (skipped),
/// rather than letting 's null-node
/// "inside" default make it spuriously claim every point.
///
///
public static uint FindVisibleChildCell(
PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList)
{
var start = cache.GetCellStruct(startCellId);
if (start is null) return 0u;
// this->point_in_cell(point) → return this (:311402-311405)
if (PointInCell(start, worldPoint)) return startCellId;
if (useStabList)
{
// arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds)
if (PointInCell(cache.GetCellStruct(id), worldPoint)) return id;
}
else
{
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals)
if (PointInCell(cache.GetCellStruct(portal.OtherCellId), worldPoint)) return portal.OtherCellId;
}
return 0u;
}
///
/// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then .
/// A cell with no hydrated returns false (see
/// 's adaptation note).
///
private static bool PointInCell(CellPhysics? cell, Vector3 worldPoint)
{
if (cell?.CellBSP?.Root is null) return false;
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
return BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local);
}
///
/// Top-level cell-tracking driver, ported from retail's
/// CObjCell::find_cell_list (sphere variant).
///
///
/// Walks the portal graph from ,
/// finds the cell whose contains
/// the sphere center, and returns its full id (landblock-prefixed).
/// Falls back to when no candidate
/// matches. The candidate set built internally is discarded; use
/// to recover it.
///
///
///
/// Pseudocode reference:
/// docs/research/acclient_indoor_transitions_pseudocode.md
/// §"Overall Driver: find_cell_list".
///
///
public static uint FindCellList(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId)
{
return FindCellSet(cache, worldSphereCenter, sphereRadius, currentCellId, out _);
}
///
/// Phase A4 (2026-05-20). Same portal-graph traversal as
/// but additionally returns the full
/// candidate set built during traversal. Used by
/// to iterate every cell
/// the sphere overlaps for per-cell BSP collision.
///
///
/// Retail oracle: CTransition::check_other_cells at
/// acclient_2013_pseudo_c.txt:272717-272798 calls
/// CObjCell::find_cell_list(&this->cell_array, &var_4c, ...)
/// which fills both the cell_array (set) and var_4c (containing cell).
///
///
public static uint FindCellSet(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out IReadOnlyCollection cellSet)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet);
}
///
/// Multi-sphere form of .
/// Containment still uses sphere 0's center, matching retail's
/// CObjCell::find_cell_list loop after the transit set is built.
///
public static uint FindCellSet(
PhysicsDataCache cache,
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
out IReadOnlyCollection cellSet)
{
var containing = BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId,
out var candidates);
cellSet = candidates;
return containing;
}
private static uint BuildCellSetAndPickContaining(
PhysicsDataCache cache,
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
out 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 = new CellArray();
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return currentCellId;
Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu;
// #106: the current block's world origin converts the world-frame sphere
// coords into retail's block-local frame for the LandDefs lcoord math.
// Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero —
// the legacy anchor-frame assumption (world frame == block-local frame).
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
// #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; ifind_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)
{
// 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 (cand?.CellBSP?.Root is null) continue;
var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
return candId; // interior-wins, stop (pseudo_c:308819)
}
else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed)
{
// 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 — is
// handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition
// validation since #107/#111; 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?.CellBSP?.Root is not null)
{
var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform);
if (!BSPQuery.SphereIntersectsCellBsp(cur.CellBSP.Root, curLocal, sphereRadius))
{
uint recovered = FindVisibleChildCell(
cache, currentCellId, worldSphereCenter, useStabList: true);
if (recovered != 0u && recovered != currentCellId)
return recovered;
}
}
}
return currentCellId;
}
private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
}
}