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;
///
/// 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.
///
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)
{
// A6.P5 (2026-05-25): exit portals add outdoor cells
// UNCONDITIONALLY, by topology — not by sphere-plane overlap.
//
// Retail's CObjCell::find_cell_list (acclient_2013_pseudo_c.txt
// :308742-308869) walks vtable[0x80] on every cell already in
// the array and adds reachable cells without testing the
// sphere against each portal plane. The straddle check we
// had here gated outdoor inclusion on the sphere physically
// overlapping the EXIT portal — which fails to fire when:
// a) the sphere is in a SIBLING indoor cell that BFS-
// expanded to this one (sphere is geographically near
// the doorway region, just not at THIS cell's exit
// portal plane); OR
// b) the per-tick target moves the sphere across the
// portal plane on one tick but not the next, producing
// intermittent visibility from the same position.
//
// Pre-fix bug: cottage doors at outdoor cells were invisible
// from indoor cells during cell-crossing substeps (live
// capture 2026-05-25; over-penetration test in
// CellTransitTests.A6P5_BuildCellSetFromIndoorStart_...).
//
// Post-fix: any cell visited by BFS that has at least one
// exit portal contributes exitOutside=true regardless of
// sphere position. AddAllOutsideCells fires once per BFS
// (deduped in BuildCellSetAndPickContaining).
exitOutside = true;
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) per the
/// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
///
///
/// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
/// within a landblock is computed from local X/Y mod 24. The sphere
/// adds the primary cell plus up to 3 neighbours when the radius
/// reaches a cell boundary.
///
///
///
/// is in the landblock-local coord
/// space the rest of the engine uses (X/Y in [0, 192]; landblock
/// world origin is at the streaming center, so all landblock-local
/// positions are also world positions for the player's landblock).
///
///
///
/// A6.P4 door fix (2026-05-24): pre-fix this function subtracted the
/// landblock's "absolute" world origin (lbX=0xA9*192=32448) from the
/// sphere position, which made sense only if sphere coords were the
/// absolute world position (32580). But production has used
/// landblock-local coords since Phase A.1 (streaming-center landblock
/// at world origin, so lbOffset for the center is (0,0); see
/// GameWindow.BuildInteriorEntitiesForStreaming's lbOffset
/// formula). With landblock-local sphere coords, the old subtraction
/// produced localX = 132.36 - 32448 = -32316 → gridX = -1346
/// → out-of-range → early return → ZERO outdoor cells added. For
/// indoor primary cells (where issue #98 gates the GetNearbyObjects
/// outdoor radial sweep) this meant the cottage door's outdoor cell
/// 0xA9B40029 never reached portalReachableCells, the door's
/// BSP was never queried, and the player walked through unimpeded —
/// the user-reported Holtburg-door walkthrough bug. The fix:
/// treat worldSphereCenter as landblock-local directly, no
/// landblock-world-origin subtraction. This matches retail's
/// CLandCell::add_all_outside_cells which uses the per-cell
/// 6-byte position struct (landblock-relative).
///
///
public static void AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
ICollection candidates)
{
const float CellSize = 24f;
uint lbPrefix = currentCellId & 0xFFFF0000u;
float localX = worldSphereCenter.X;
float localY = worldSphereCenter.Y;
float cellLocalX = localX % CellSize;
float cellLocalY = localY % CellSize;
float minRad = sphereRadius;
float maxRad = CellSize - sphereRadius;
int gridX = (int)(localX / CellSize);
int gridY = (int)(localY / CellSize);
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
AddOutsideCell(candidates, lbPrefix, gridX, gridY);
if (cellLocalX > maxRad)
{
AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
}
if (cellLocalX < minRad)
{
AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
}
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
}
///
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
/// path sphere and adds the outdoor landcells touched by any of them.
///
public static void AddAllOutsideCells(
IReadOnlyList worldSpheres,
int numSpheres,
uint currentCellId,
ICollection candidates)
{
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, candidates);
}
}
private static void AddOutsideCell(ICollection candidates, uint lbPrefix, int gridX, int gridY)
{
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
// Cell index within landblock: row-major (X * 8 + Y) + 1.
uint low = (uint)(gridX * 8 + gridY + 1);
candidates.Add(lbPrefix | low);
}
///
/// 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)
{
foreach (var portal in building.Portals)
{
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.
var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
bool inside = BSPQuery.SphereIntersectsCellBsp(otherCell.CellBSP.Root, localCenter, sphereRadius);
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[check-bldg] portal->0x{portal.OtherCellId:X8} wpos=({worldSphereCenter.X:F3},{worldSphereCenter.Y:F3},{worldSphereCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) r={sphereRadius:F3} inside={inside}"));
}
if (inside)
{
candidates.Add(portal.OtherCellId);
}
}
}
///
/// 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;
uint lbPrefix = currentCellId & 0xFFFF0000u;
if (currentLow >= 0x0100u)
{
// Indoor seed: the CURRENT cell is added at INDEX 0 (retail
// CObjCell::find_cell_list add_cell @ pseudo_c:308766). Index 0 is what
// makes the pick current-cell-first — the hysteresis that stops the flap.
var currentCell = cache.GetCellStruct(currentCellId);
if (currentCell is null) return currentCellId;
candidates.Add(currentCellId);
// EXPAND — a single forward walk over the GROWING array, mirroring
// retail's `for (i=0; i(candidates.OrderedIds);
foreach (uint landcellId in landcellSnapshot)
{
var building = cache.GetBuilding(landcellId);
if (building is null) continue;
CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
}
}
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.)
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)
{
// 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.
int gx = (int)(worldSphereCenter.X / 24f);
int gy = (int)(worldSphereCenter.Y / 24f);
if (gx >= 0 && gx < 8 && gy >= 0 && gy < 8)
{
uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1);
if (candId == outdoorId)
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).
return outdoorResult != 0u ? outdoorResult : currentCellId;
}
private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
}
}