The oracle read the #112 residual was waiting on, settled against the LIVE 2013 client (cdb attach, CEnvCell::find_transit_cells @ 0052c820; BN pseudo-C was ambiguous and partly wrong per feedback_bn_decomp_field_names - it invented portal_side tests in this branch): retail admits outdoor transit cells from an indoor cell IFF a path sphere STRADDLES an exterior portal polygon plane, |dist| < radius + F_EPSILON(0.000199999995, @ 007c8c70). The flag at [esp+18h] (set 0052c925, x87 decode fcompp/test ah,41h + fcomp/test ah,5/jp) gates the add_all_outside_cells call (0052c9d6 je). Graph reachability alone NEVER admits outdoor cells in retail. Port (CellTransit): - FindTransitCellsSphere: exitOutside now carries the retail straddle semantics; new hasExitPortal out carries the old topology-only flag. - BuildCellSetAndPickContaining: the collision cell SET keeps the A6.P5 topology widening on hasExitPortal (outdoor-registered doors must stay findable from indoor cells until #99/A6.P4 ships per-cell shadow lists - the 2026-05-25 door capture scenario), but the membership PICK's outdoor branch is gated on the retail flag. Membership is now retail-identical in both regimes: straddle -> outdoor candidates valid; no straddle -> outdoor ignored -> retail keep-curr. This is what stops deep-interior containment gaps in ANY house from demoting to outdoor (the #112 transparent-interior shape) - the systemic protection the user asked for, without house-by-house verification. The at-doorway A9B3 gap demote is RETAIL-FAITHFUL (gap point is 0.23m from 0x104s door plane < 0.48 foot radius -> retail straddles + demotes + self-heals inward): DocumentsResidual renamed to ...DemotesRetailFaithfully, expectation unchanged. New conformance pins: deep-gap keep-curr (A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell) + function-level gate semantics on real dat geometry (FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail). Tests: Core 1391 green (+2) / App 224 / UI 420 / Net 294; pre-existing 4 #99-era failures unchanged; P1 membership goldens + A6.P5 door-set tests explicitly green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
740 lines
34 KiB
C#
740 lines
34 KiB
C#
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)
|
||
=> FindTransitCellsSphere(
|
||
cache, currentCell, currentCellId, worldSphereCenter, sphereRadius,
|
||
candidates, out exitOutside, out _);
|
||
|
||
/// <inheritdoc cref="FindTransitCellsSphere(PhysicsDataCache, CellPhysics, uint, IReadOnlyList{Sphere}, int, ICollection{uint}, out bool, out bool)"/>
|
||
public static void FindTransitCellsSphere(
|
||
PhysicsDataCache cache,
|
||
CellPhysics currentCell,
|
||
uint currentCellId,
|
||
Vector3 worldSphereCenter,
|
||
float sphereRadius,
|
||
ICollection<uint> candidates,
|
||
out bool exitOutside,
|
||
out bool hasExitPortal)
|
||
{
|
||
var spheres = new[]
|
||
{
|
||
new Sphere
|
||
{
|
||
Origin = worldSphereCenter,
|
||
Radius = sphereRadius,
|
||
},
|
||
};
|
||
|
||
FindTransitCellsSphere(
|
||
cache, currentCell, currentCellId,
|
||
spheres, spheres.Length, candidates, out exitOutside, out hasExitPortal);
|
||
}
|
||
|
||
/// <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| < 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.</param>
|
||
/// <param name="hasExitPortal">Topology-only: this cell has at least one
|
||
/// exterior (0xFFFF) portal, regardless of sphere position. NOT retail —
|
||
/// kept for the A6.P5 collision cell-set widening (outdoor-registered door
|
||
/// entities must stay findable from indoor cells until the A6.P4 per-cell
|
||
/// shadow architecture ships; see the exterior-portal branch comment).</param>
|
||
public static void FindTransitCellsSphere(
|
||
PhysicsDataCache cache,
|
||
CellPhysics currentCell,
|
||
uint currentCellId,
|
||
IReadOnlyList<Sphere> worldSpheres,
|
||
int numSpheres,
|
||
ICollection<uint> candidates,
|
||
out bool exitOutside,
|
||
out bool hasExitPortal)
|
||
{
|
||
exitOutside = false;
|
||
hasExitPortal = 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)
|
||
{
|
||
hasExitPortal = true;
|
||
|
||
// #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 and 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). The A6.P5
|
||
// symptom it fixed (outdoor-registered cottage DOORS invisible
|
||
// to collision from indoor cells) is really the missing
|
||
// per-cell shadow_object_list (#99/A6.P4): retail finds those
|
||
// doors via shadow lists, not via outdoor transit cells. Until
|
||
// A6.P4 ships, BuildCellSetAndPickContaining keeps widening the
|
||
// COLLISION cell set on hasExitPortal — but the membership PICK
|
||
// gates its outdoor branch on this retail flag, which is what
|
||
// keeps deep-interior containment gaps on curr_cell (retail
|
||
// keep-curr) instead of demoting to outdoor (#112).
|
||
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 >/< 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)
|
||
{
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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(&this->cell_array, &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;
|
||
|
||
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<num_cells; i++) cells[i].find_transit_cells(...)`
|
||
// loop (pseudo_c:308775-308785). FindTransitCellsSphere APPENDS portal
|
||
// neighbours (and, on an exit portal, the outdoor landcells) to the same
|
||
// array; CellArray.Add dedups, so the walk terminates when no new cell is
|
||
// appended. Read OrderedIds[i] by index because the list grows under us.
|
||
bool outdoorAdded = false;
|
||
for (int i = 0; i < candidates.Count; i++)
|
||
{
|
||
uint cellId = candidates.OrderedIds[i];
|
||
var cell = cache.GetCellStruct(cellId);
|
||
if (cell is null) continue;
|
||
|
||
FindTransitCellsSphere(
|
||
cache, cell, cellId, worldSpheres, sphereCount,
|
||
candidates, out bool exitOutsideStraddle, out bool hasExitPortal);
|
||
|
||
// #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;
|
||
|
||
// A6.P5 (kept, NARROWED to the collision cell SET): the first
|
||
// exit-portal cell triggers the outdoor neighbourhood add once, by
|
||
// TOPOLOGY — wider than retail. This keeps outdoor-registered door
|
||
// entities findable from indoor cells (the 2026-05-25 door capture)
|
||
// until #99/A6.P4 ships per-cell shadow lists; the pick no longer
|
||
// consumes these cells unless the retail flag fired, so membership
|
||
// matches retail in both regimes. Appended AFTER the interior cells,
|
||
// matching retail order (add_all_outside_cells at the end,
|
||
// pseudo_c:310120) — interior-wins is preserved.
|
||
if (hasExitPortal && !outdoorAdded)
|
||
{
|
||
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
|
||
outdoorAdded = true;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Outdoor seed: expand neighbour landcells (added first), then check each
|
||
// for a building stab whose portals cross into an interior EnvCell.
|
||
// (Stage 2 will make building entry intrinsic and remove CheckBuildingTransit.)
|
||
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
|
||
|
||
var landcellSnapshot = new List<uint>(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.)
|
||
//
|
||
// #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;
|
||
}
|
||
}
|