Gate-2 fallout chain: session 1's bare-id wedge poisoned ACE's save (the client reported garbage cells while walking through walls), so session 2 logged in with (cell=0xA9B4013F inn interior, pos=(91.4,32.7)) — a position that cell does not contain. Probe evidence: exactly one [cell-transit] line all session; the player free-fell into an empty world. Two holes, both fixed at the root: 1. CellTransit pick escape hatch — restores the #83/A1.7 + #90 verification that lived in PhysicsEngine.ResolveCellId before the collide-then-pick rewrite moved membership into BuildCellSetAndPickContaining: an indoor current cell that IS hydrated but whose CellBSP no longer overlaps ANY part of the foot sphere is a bogus claim (corrupt save, or walked out through an unblocked gap). The portal BFS can never reach an exit portal from a cell the sphere isn't in, so no candidates exist and the claim held forever — wedging collision (ShadowObjectRegistry's #98 gate reads "indoor primary" -> outdoor object sweep skipped), wall BSP, terrain, and the render root. The pick now demotes to the outdoor column under the sphere centre (the LandDefs.AdjustToOutside result already computed for the pick — cross-block safe). Sphere-overlap (BSPQuery.SphereIntersectsCellBsp, pseudo_c:317666 -> :323267), NOT point-in: doorway push-back leaves the centre a few cm outside while the sphere still overlaps — no demotion, #90's ping-pong stays dead. An unhydrated cell cannot be verified — stale beats null while streaming hydrates (retail-equivalent: stale curr_cell kept when the pick finds nothing). 2. PlayerModeAutoEntry spawn-ground hold — player-mode entry now waits for the terrain under the spawn position to stream in (isSpawnGroundReady predicate, K.2 pattern). Entering earlier integrates gravity against an empty world: indoor-claimed spawns got no floor from any source and free-fell into the void; outdoor spawns raced hydration by ~1s every login. Retail never has this state (it loads cells synchronously) — the hold is the async-streaming equivalent of that invariant. With the hold, the entry snap (Resolve, stepUp=100) runs against hydrated cell floors + terrain and re-seats a corrupt save's claim immediately. Tests: IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn (the gate-2 wedge shape, red pre-fix), straddle + no-BSP guards (the #90 hysteresis and stale-beats-null), TryEnter_Armed_SpawnGroundNotReady_ DoesNotFire. Full suite: 294+218+420 green; Core 1375 green + the same 4 pre-existing door/#99-era failures + 1 skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
663 lines
30 KiB
C#
663 lines
30 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>
|
||
/// 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>
|
||
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)
|
||
{
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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-block assumption (world frame == block-local frame).
|
||
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
|
||
|
||
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 exitOutside);
|
||
|
||
// A6.P5 (kept): the first exit-portal cell triggers the outdoor
|
||
// neighbourhood add once. Appended AFTER the interior cells, matching
|
||
// retail (CEnvCell::find_transit_cells calls add_all_outside_cells at
|
||
// the end, pseudo_c:310120) — so interior cells precede outdoor in the
|
||
// pick order and interior-wins is preserved.
|
||
if (exitOutside && !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)
|
||
{
|
||
// 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.
|
||
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;
|
||
|
||
// ── #106 gate-2 escape hatch: bogus-indoor-claim recovery ──────────
|
||
// Restores the #83/A1.7 + #90 verification that lived in
|
||
// PhysicsEngine.ResolveCellId before the collide-then-pick rewrite
|
||
// moved membership here: an INDOOR current cell that IS hydrated but
|
||
// whose CellBSP no longer overlaps ANY part of the foot sphere is a
|
||
// bogus claim — a corrupt server save pairing an indoor cell with a
|
||
// position far outside it, or the player walked out through an
|
||
// unblocked gap. Keeping it wedges everything downstream: the BFS
|
||
// can't reach an exit portal from a cell the sphere isn't in (no
|
||
// candidates → frozen), ShadowObjectRegistry's #98 gate reads
|
||
// "indoor primary" (no object collision anywhere), and there's no
|
||
// wall BSP and no terrain (void fall). Demote to the outdoor column
|
||
// under the sphere centre (LandDefs global math — cross-block safe).
|
||
//
|
||
// Sphere-overlap (BSPQuery.SphereIntersectsCellBsp,
|
||
// pseudo_c:317666→:323267), NOT point-in: a doorway push-back leaves
|
||
// the centre a few cm outside while the sphere still overlaps — that
|
||
// must NOT demote (#90's ping-pong). A cell with no hydrated CellBSP
|
||
// cannot be verified — trust the claim (stale beats null while
|
||
// streaming hydrates).
|
||
if (currentLow >= 0x0100u && containingOutdoorId != 0u)
|
||
{
|
||
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))
|
||
return containingOutdoorId;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|