acdream/src/AcDream.Core/Physics/CellTransit.cs
Erik 7ac8f544a7 fix(physics): #89 — sphere-overlap in CheckBuildingTransit closes login-inside-inn classification race
Outdoor→indoor entry path used PointInsideCellBsp (point-only) for the
building-portal containment test. When the player logs in INSIDE a
building and the foot-sphere center is just past the destination cell's
CellBSP boundary, the point-only check failed → CellId stuck as
outdoor → indoor BSP queries never ran → walls passable. User-reported
symptom: "logged in in the inn, at start ran through exterior walls,
ran back in and they block now."

Fix: swap PointInsideCellBsp for SphereIntersectsCellBsp (the radius-
aware port from #90). Promotes CellId to the interior cell the moment
ANY part of the foot-sphere crosses the destination cell boundary —
matches retail's CCellStruct::sphere_intersects_cell timing at
acclient_2013_pseudo_c.txt:317666 exactly.

The sphereRadius parameter was already plumbed through CheckBuildingTransit
per #89's documented "future upgrade" note from 2026-05-19 (which is
exactly today's symptom). Closes #89.

1147 + 8 baseline maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:12:30 +02:00

370 lines
15 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;
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 single foot sphere).
///
/// <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,
HashSet<uint> candidates,
out bool exitOutside)
{
exitOutside = false;
if (currentCell.PortalPolygons is null) return;
uint lbPrefix = currentCellId & 0xFFFF0000u;
float rad = sphereRadius + EPSILON;
// Cell-local sphere center.
var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform);
foreach (var portal in currentCell.Portals)
{
if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
continue;
// Signed distance from sphere center to portal plane (cell-local).
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
if (portal.OtherCellId == 0xFFFF)
{
// Exit portal. Sphere must straddle the plane.
if (dist > -rad && dist < rad)
exitOutside = true;
continue;
}
uint otherId = lbPrefix | portal.OtherCellId;
// Conservative add: the sphere is near the portal plane and on the
// outward side (per PortalSide). This is the load-hint branch from
// the research doc. A more retail-faithful path would call
// CellBSP.sphere_intersects_cell on the neighbour — deferred.
if (portal.PortalSide ? dist > -rad : dist < rad)
candidates.Add(otherId);
}
}
/// <summary>
/// Outdoor neighbour expansion. Ported from
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant) per the
/// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
///
/// <para>
/// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
/// within a landblock is computed from local X/Y mod 24. The sphere
/// adds the primary cell plus up to 3 neighbours when the radius
/// reaches a cell boundary.
/// </para>
/// </summary>
public static void AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
HashSet<uint> candidates)
{
const float CellSize = 24f;
uint lbPrefix = currentCellId & 0xFFFF0000u;
float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f;
float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f;
float localX = worldSphereCenter.X - lbXf;
float localY = worldSphereCenter.Y - lbYf;
float cellLocalX = localX % CellSize;
float cellLocalY = localY % CellSize;
float minRad = sphereRadius;
float maxRad = CellSize - sphereRadius;
int gridX = (int)(localX / CellSize);
int gridY = (int)(localY / CellSize);
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
AddOutsideCell(candidates, lbPrefix, gridX, gridY);
if (cellLocalX > maxRad)
{
AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
}
if (cellLocalX < minRad)
{
AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
}
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
}
private static void AddOutsideCell(HashSet<uint> candidates, uint lbPrefix, int gridX, int gridY)
{
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
// Cell index within landblock: row-major (X * 8 + Y) + 1.
uint low = (uint)(gridX * 8 + gridY + 1);
candidates.Add(lbPrefix | low);
}
/// <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,
HashSet<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>
/// 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 BuildCellSetAndPickContaining(
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 containing = BuildCellSetAndPickContaining(
cache, worldSphereCenter, sphereRadius, currentCellId,
out var candidates);
cellSet = candidates;
return containing;
}
private static uint BuildCellSetAndPickContaining(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out HashSet<uint> candidates)
{
candidates = new HashSet<uint>();
uint currentLow = currentCellId & 0xFFFFu;
if (currentLow >= 0x0100u)
{
// Indoor seed.
var currentCell = cache.GetCellStruct(currentCellId);
if (currentCell is null) return currentCellId;
candidates.Add(currentCellId);
// BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
var pending = new Queue<uint>();
var visited = new HashSet<uint>();
pending.Enqueue(currentCellId);
visited.Add(currentCellId);
int maxIterations = 16; // hard cap; portal graphs are small
while (pending.Count > 0 && maxIterations-- > 0)
{
uint cellId = pending.Dequeue();
var cell = cache.GetCellStruct(cellId);
if (cell is null) continue;
var sizeBefore = candidates.Count;
FindTransitCellsSphere(
cache, cell, cellId, worldSphereCenter, sphereRadius,
candidates, out bool exitOutside);
if (candidates.Count > sizeBefore)
{
foreach (var c in candidates)
{
if (visited.Add(c)) // only enqueue if NEW
pending.Enqueue(c);
}
}
if (exitOutside)
{
// Add neighbour outdoor cells too.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
}
}
}
else
{
// Outdoor seed: expand neighbour landcells AND check for building stabs
// with portals into interior EnvCells.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
// For each landcell candidate, see if it carries a building stab; if so,
// check whether the sphere has crossed into any of the building's interior
// EnvCells via CheckBuildingTransit.
//
// NOTE: PhysicsEngine.ResolveCellId currently bypasses this entire branch
// for outdoor seeds (it uses its own _landblocks terrain grid loop). The
// outdoor→indoor production path therefore runs through ResolveCellId's
// OWN outdoor branch (see below for the call there too). This block is
// exercised by direct-FindCellList callers (tests, future re-entry from
// an indoor cell exiting through a portal that lands outside near a
// building).
var landcellSnapshot = new List<uint>(candidates);
foreach (uint landcellId in landcellSnapshot)
{
var building = cache.GetBuilding(landcellId);
if (building is null) continue;
CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
}
}
// Containment test: for each candidate, transform worldSphereCenter to
// local and test PointInsideCellBsp.
foreach (uint candId in candidates)
{
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;
}
// No cell contained the sphere center. Stay in the input cell.
return currentCellId;
}
}