using System.Collections.Generic; using System.Numerics; using DatReaderWriter.Types; namespace AcDream.Core.Physics; /// /// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, /// ported from retail's CObjCell::find_cell_list family /// (sphere variant for the player's path spheres). /// /// /// Replaces Phase D's AABB containment. Uses the cell BSP for retail- /// faithful point-in-cell tests via /// . Walks the portal graph /// starting from a given current cell to find which cells a moving /// sphere overlaps. /// /// /// /// Reference pseudocode: /// docs/research/acclient_indoor_transitions_pseudocode.md /// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells /// (sphere variant) at acclient_2013_pseudo_c.txt. /// /// public static class CellTransit { /// /// Small radius padding matching retail's EPSILON usage in the /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells"). /// private const float EPSILON = 0.02f; /// /// Indoor portal-neighbour expansion. For each portal of /// , test whether the sphere overlaps /// the portal polygon's plane in cell-local space. If so, add the /// neighbour cell to . /// /// /// Ported from CEnvCell::find_transit_cells (sphere variant) /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)". /// /// public static void FindTransitCellsSphere( PhysicsDataCache cache, CellPhysics currentCell, uint currentCellId, Vector3 worldSphereCenter, float sphereRadius, HashSet candidates, out bool exitOutside) { var spheres = new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius, }, }; FindTransitCellsSphere( cache, currentCell, currentCellId, spheres, spheres.Length, candidates, out exitOutside); } /// /// Multi-sphere form used by retail's CObjCell::find_cell_list: /// pass sphere_path.num_sphere and sphere_path.global_sphere. /// Any sphere can trigger a portal neighbor or outdoor exit. /// public static void FindTransitCellsSphere( PhysicsDataCache cache, CellPhysics currentCell, uint currentCellId, IReadOnlyList worldSpheres, int numSpheres, HashSet 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; } } } } /// /// Outdoor neighbour expansion. Ported from /// CLandCell::add_all_outside_cells (sphere variant) per the /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)". /// /// /// 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. /// /// public static void AddAllOutsideCells( Vector3 worldSphereCenter, float sphereRadius, uint currentCellId, HashSet 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); } /// /// Multi-sphere outdoor expansion. Retail's sphere variant loops every /// path sphere and adds the outdoor landcells touched by any of them. /// public static void AddAllOutsideCells( IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, HashSet 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 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); } /// /// Outdoor→indoor entry path. Ported from retail's /// BuildingObj::find_building_transit_cells + /// EnvCell::check_building_transit. For each portal of the /// outdoor building, look up the destination interior cell and test /// whether the sphere overlaps it via /// . If so, add the /// interior cell to . /// /// /// Issue #89 closed (2026-05-20): uses retail's radius-aware /// CCellStruct::sphere_intersects_cell /// (acclient_2013_pseudo_c.txt:317666) ported as /// . 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. /// /// public static void CheckBuildingTransit( PhysicsDataCache cache, BuildingPhysics building, Vector3 worldSphereCenter, float sphereRadius, HashSet 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); } } } /// /// Top-level cell-tracking driver, ported from retail's /// CObjCell::find_cell_list (sphere variant). /// /// /// Walks the portal graph from , /// finds the cell whose contains /// the sphere center, and returns its full id (landblock-prefixed). /// Falls back to when no candidate /// matches. The candidate set built internally is discarded; use /// to recover it. /// /// /// /// Pseudocode reference: /// docs/research/acclient_indoor_transitions_pseudocode.md /// §"Overall Driver: find_cell_list". /// /// public static uint FindCellList( PhysicsDataCache cache, Vector3 worldSphereCenter, float sphereRadius, uint currentCellId) { return FindCellSet(cache, worldSphereCenter, sphereRadius, currentCellId, out _); } /// /// Phase A4 (2026-05-20). Same portal-graph traversal as /// but additionally returns the full /// candidate set built during traversal. Used by /// to iterate every cell /// the sphere overlaps for per-cell BSP collision. /// /// /// Retail oracle: CTransition::check_other_cells at /// acclient_2013_pseudo_c.txt:272717-272798 calls /// CObjCell::find_cell_list(&this->cell_array, &var_4c, ...) /// which fills both the cell_array (set) and var_4c (containing cell). /// /// public static uint FindCellSet( PhysicsDataCache cache, Vector3 worldSphereCenter, float sphereRadius, uint currentCellId, out IReadOnlyCollection cellSet) { var spheres = new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius, }, }; return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet); } /// /// Multi-sphere form of . /// Containment still uses sphere 0's center, matching retail's /// CObjCell::find_cell_list loop after the transit set is built. /// public static uint FindCellSet( PhysicsDataCache cache, IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, out IReadOnlyCollection cellSet) { var containing = BuildCellSetAndPickContaining( cache, worldSpheres, numSpheres, currentCellId, out var candidates); cellSet = candidates; return containing; } private static uint BuildCellSetAndPickContaining( PhysicsDataCache cache, IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, out HashSet candidates) { candidates = new HashSet(); 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(); var visited = new HashSet(); 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(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 worldSpheres, int numSpheres) { if (numSpheres <= 0 || worldSpheres.Count == 0) return 0; return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count; } }