acdream/src/AcDream.Core/Physics/CellTransit.cs
Erik 35b37dfb5f chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments
Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
2026-05-23 15:11:49 +02:00

493 lines
19 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>
/// 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)
{
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,
HashSet<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)
{
// Exit portal. Any path sphere straddling the plane triggers
// the outdoor cell expansion.
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 = dist > -rad && dist < rad;
if (hit)
{
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) 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);
}
/// <summary>
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
/// path sphere and adds the outdoor landcells touched by any of them.
/// </summary>
public static void AddAllOutsideCells(
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
HashSet<uint> candidates)
{
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, candidates);
}
}
private static void AddOutsideCell(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 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 HashSet<uint> candidates)
{
candidates = new HashSet<uint>();
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return currentCellId;
Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius;
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, worldSpheres, sphereCount,
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(worldSpheres, sphereCount, currentCellId, candidates);
}
}
}
else
{
// Outdoor seed: expand neighbour landcells AND check for building stabs
// with portals into interior EnvCells.
AddAllOutsideCells(worldSpheres, sphereCount, 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;
}
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
}
}