acdream/src/AcDream.Core/Physics/CellTransit.cs
Erik be03146e30 #112 ROOT CAUSE: outdoor-seed pick lacked retail's growing-array walk - threshold tick-skip became absorbing
The instrumented capture (cottage-112-capture1.log) + dat replay pinned
the transparent-cottage mechanism end to end:

1. The A9B3 cottage's entry cell 0x104 is a 0.22 m-wide THRESHOLD band
   (x 184.68->184.46 at y~82). A running player (~13-16 cm/tick at
   30 Hz) can cross it BETWEEN two physics ticks - the tick where the
   centre is inside 0x104 never happens.
2. Our outdoor-seed branch ran CheckBuildingTransit over a landcell
   snapshot and STOPPED - building-admitted entry cells were never
   expanded. The tick after the skip (centre in 0x100, a deep room not
   building-portal-adjacent) found no containing candidate -> the pick
   kept the outdoor landcell FOREVER (absorbing): the user walked the
   whole interior classified outdoor (render faithfully drew an outdoor
   frame = transparent walls), promoting only on touching
   portal-adjacent 0x102's own volume minutes later (captured:
   0xA9B3003C -> 0xA9B30102 with no transitions in between).
3. Retail cannot strand: CObjCell::find_cell_list (0x0052b4e0) runs ONE
   growing-array walk for EVERY seed (0052b576-0052b5ab,
   cells[i]->find_transit_cells vtable dispatch over the GROWING array)
   - the landcell's building bridge admits 0x104 (the foot sphere still
   overlaps the band one tick after the skip) and the walk expands
   0x104's portals to 0x100 where containment wins. Recovery fires one
   tick after any skip.

Fix: BuildCellSetAndPickContaining now runs retail's single growing
walk for both seeds with per-cell-type dispatch (landcells ->
CLandCell::find_transit_cells 0x00533800 -> CSortCell 0x00534060 ->
check_building_transit 0x0052c5d0; envcells -> FindTransitCellsSphere
with the straddle gate + once-per-walk outside add). The old indoor
branch behavior is preserved (seed at index 0, hysteresis, straddle-
gated outdoor pick); the outdoor branch gains the expansion + the
indoor branch gains the retail landcell bridge dispatch for
straddle-admitted landcells.

Pins (dat-backed, Issue112MembershipTests): tick-skip recovery one tick
past the threshold (RED pre-fix); run-speed entry replay across tick
phases never strands outdoor; threshold-gap outdoor-seed keeps outdoor
(over-fix guard); entry-walk replay diagnostic prints the full
promotion chain (0x3C -> 0x104 -> 0x100 -> 0x103 -> 0x100 -> 0x102).

Suites: App 246+1skip / Core 1438+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:35:52 +02:00

928 lines
43 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 (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;
}
}
}
}
/// <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;
}
}
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;
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);
}
}
}
/// <summary>
/// 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 <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);
// 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<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>
/// acdream adaptation (matches <see cref="FindCellList"/> at line 518): a cell
/// with no hydrated <see cref="CellPhysics.CellBSP"/> cannot run
/// <c>point_in_cell</c>, so it is treated as NOT containing the point (skipped),
/// rather than letting <see cref="BSPQuery.PointInsideCellBsp"/>'s null-node
/// "inside" default make it spuriously claim every point.
/// </para>
/// </summary>
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;
}
/// <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 cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see
/// <see cref="FindVisibleChildCell"/>'s adaptation note).
/// </summary>
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);
}
/// <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)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet);
}
/// <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)
{
var containing = BuildCellSetAndPickContaining(
cache, worldSpheres, numSpheres, currentCellId,
out var candidates);
cellSet = candidates;
return containing;
}
private static uint BuildCellSetAndPickContaining(
PhysicsDataCache cache,
IReadOnlyList<Sphere> 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; 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)
{
// 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<Sphere> worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
}
}