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; /// /// Retail F_EPSILON (acclient.exe data @ 007c8c70, 0.000199999995f) — /// the pad added to the sphere radius in the exterior-portal straddle test /// (fadd [ecx+4] at 0052c8eb, #112 rider live-binary read 2026-06-10). /// private const float FEpsilon = 0.000199999995f; /// /// 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, ICollection 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. /// /// 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 — |dist| < radius + EPSILON. /// This is the only condition under which retail's /// CEnvCell::find_transit_cells calls add_all_outside_cells /// (acclient.exe 0052c8e5-0052c92d straddle test, 0052c9d2-0052c9f0 gate; /// pseudo-C :310070-310120). Drives the membership pick's outdoor branch /// AND (BR-7 C4, retail-faithfully) the collision cell-set outside-add — /// the former hasExitPortal topology widening is deleted. public static void FindTransitCellsSphere( PhysicsDataCache cache, CellPhysics currentCell, uint currentCellId, IReadOnlyList worldSpheres, int numSpheres, ICollection 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) { // #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, 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), and was restored for the // membership PICK by the #112 rider. BR-7 / A6.P4 C4 // (2026-06-11) finished the story: the per-cell shadow // architecture made the A6.P5 hasExitPortal topology widening // unnecessary (doors are found in the straddle-admitted outdoor // cell's own list), so this flag now gates BOTH the pick's // outdoor branch AND the collision cell-set outside-add — // pure retail. 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; } } } } /// /// Outdoor neighbour expansion. Ported from /// CLandCell::add_all_outside_cells (sphere variant, /// pc:317499 @0x00533630) per /// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md. /// /// /// Retail runs this in the GLOBAL landcell grid ( /// lcoords, 0..2039 across the whole map): adjust_to_outside re-seats /// the (cell, position) pair onto the landcell actually under the sphere — /// crossing landblock boundaries when floor(local/24) leaves the /// current block's 8×8 grid — and check_add_cell_boundary 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. /// /// /// /// is in the floating world frame /// (anchor landblock at origin — the convention every physics caller uses); /// is the current cell's landblock /// world origin (SW corner; ), /// which converts it to retail's block-local frame. Pass /// 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. /// /// /// /// False when adjust_to_outside rejects the position (map edge / /// invalid cell id) — retail breaks out of the sphere loop on that. /// public static bool AddAllOutsideCells( Vector3 worldSphereCenter, float sphereRadius, uint currentCellId, Vector3 currentBlockOrigin, ICollection 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; } /// /// Multi-sphere outdoor expansion. Retail's sphere variant loops every /// path sphere and adds the outdoor landcells touched by any of them; /// an adjust_to_outside failure BREAKS the loop (pc:533699). /// public static void AddAllOutsideCells( IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, Vector3 currentBlockOrigin, ICollection 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 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); } /// /// 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, ICollection candidates) => CheckBuildingTransit( cache, building, new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius } }, 1, candidates, out _); /// /// Multi-sphere form matching retail's call shape: every path/flood /// sphere is tested and the FIRST one intersecting the interior cell's /// BSP admits the cell (CEnvCell::check_building_transit, /// Ghidra 0x0052c5d0 — per-sphere loop at 0052c5fe, break-on-hit). /// /// True when at least one interior cell /// was admitted — retail writes SPHEREPATH.hits_interior_cell = 1 /// at 0052c650 the moment a sphere lands a building-transit cell. Feeds /// the building-shell bldg_check weakening in /// BSPTREE::find_collisions (0x0053a440). public static void CheckBuildingTransit( PhysicsDataCache cache, BuildingPhysics building, IReadOnlyList worldSpheres, int numSpheres, ICollection candidates, out bool hitsInteriorCell) { hitsInteriorCell = false; int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); if (sphereCount == 0) return; foreach (var portal in building.Portals) { // BR-7 / A6.P4 (2026-06-11): retail's first gate — the whole // transit is rejected when other_portal_id is negative // (`if (arg2 >= 0)` at 0x0052c5dc; arg2 is the SIGNED // sign-extended CBldPortal.other_portal_id, acclient.h:32098). // Wire 0xFFFF = -1 = "no reciprocal portal". if (portal.OtherPortalId < 0) continue; 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. bool inside = false; for (int i = 0; i < sphereCount && !inside; i++) { var sphere = worldSpheres[i]; var localCenter = Vector3.Transform(sphere.Origin, otherCell.InverseWorldTransform); inside = BSPQuery.SphereIntersectsCellBsp( otherCell.CellBSP.Root, localCenter, sphere.Radius); if (PhysicsDiagnostics.ProbeIndoorBspEnabled) { Console.WriteLine(System.FormattableString.Invariant( $"[check-bldg] portal->0x{portal.OtherCellId:X8} sphere#{i} wpos=({sphere.Origin.X:F3},{sphere.Origin.Y:F3},{sphere.Origin.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) r={sphere.Radius:F3} inside={inside}")); } } if (inside) { // Retail sets SPHEREPATH.hits_interior_cell the moment a // building-transit sphere lands an interior cell (0052c650). hitsInteriorCell = true; candidates.Add(portal.OtherCellId); } } } /// /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the /// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time. /// Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, /// pc:308742) as invoked by CPhysicsObj::calc_cross_cells / /// calc_cross_cells_static (Ghidra 0x00515230 / 0x00515160): /// the seed + growing-array walk, WITHOUT the containing-cell pick /// (registration passes a null out-cell). /// /// Shape, all Ghidra-verified (wf1-interior-collision.md): /// /// Seed: indoor id (low16 ≥ 0x100) → exactly that one cell /// (0052b563), added EVEN IF unloaded (retail add_cell()s the id /// with a null pointer); outdoor id → the block-crossing /// /// (0052b53f). /// Growing-array walk (0052b576-0052b5ab), gated on the seed /// being LOADED: each array cell's find_transit_cells /// (vtable+0x80). Indoor cells → /// /// (sphere-vs-neighbor-BSP gates; exterior straddle → outside /// cells, once per walk like retail's CELLARRAY.added_outside). /// Outdoor cells → CLandCell::find_transit_cells /// (0x00533800) = add_all_outside_cells (same once-guard) + /// the building bridge CSortCell → CBuildingObj → /// CEnvCell::check_building_transit /// (0x00534060/0x006b5230/0x0052c5d0) — how an outdoor-seeded /// door reaches the vestibule's shadow list. Unloaded indoor /// cells in the array are not walked (0052b58e null check). /// Static prune (, retail /// do_not_load_cells, 0052b66e): when the seed is indoor, /// flood results are pruned to {seed} ∪ seed.stab_list /// () — placement of /// statics must not force-load cells. Note this also strips /// outdoor cells (they are never in an EnvCell stab list): /// retail interior statics never shadow into landcells; an /// outdoor sphere reaches them via its OWN array's /// check_building_transit instead. /// /// /// Flood spheres: the object's REAL collision footprint — retail /// globalizes the CylSpheres (low_pt → world, cyl radius, cap 10; /// overload 0x0052b9f0), falling back to the part-array sorting sphere. /// Callers map ShadowShape lists via /// -derived helpers (see /// ShadowObjectRegistry). /// public static IReadOnlyList BuildShadowCellSet( PhysicsDataCache cache, uint seedCellId, IReadOnlyList worldSpheres, int numSpheres, bool isStatic) { var candidates = new CellArray(); int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); if (seedCellId == 0 || sphereCount == 0) return candidates.OrderedIds; uint seedLow = seedCellId & 0xFFFFu; // #106 frame convention: LandDefs lcoord math runs block-local; // TryGetTerrainOrigin supplies the seed block's world origin // (Zero fallback = legacy anchor-frame, same as the transit path). cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin); bool seedLoaded; if (seedLow >= 0x0100u) { candidates.Add(seedCellId); // Retail adds the unloaded seed by id (null cell pointer) and // skips the walk (gate on arg4 != 0 at 0052b576) — the object // stays registered under just its claimed cell until the cell // hydrates and the registration is re-run (CObjCell::init_objects // → recalc_cross_cells, Ghidra 0x0052b420/0x00515a30; our // equivalent is ShadowObjectRegistry's re-flood hook). seedLoaded = cache.GetCellStruct(seedCellId) is not null; } else { AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); // Outdoor seeds always walk: retail's null-CLandCell case is // "landblock not loaded at all", where our per-cell building // lookups below come back null anyway (documented adaptation). seedLoaded = true; } if (seedLoaded) { bool outdoorAdded = seedLow < 0x0100u; // retail CELLARRAY.added_outside for (int i = 0; i < candidates.Count; i++) { uint cellId = candidates.OrderedIds[i]; if ((cellId & 0xFFFFu) >= 0x0100u) { var cell = cache.GetCellStruct(cellId); if (cell is null) continue; FindTransitCellsSphere( cache, cell, cellId, worldSpheres, sphereCount, candidates, out bool exitStraddle); if (exitStraddle && !outdoorAdded) { AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); outdoorAdded = true; } } else { // CLandCell::find_transit_cells (0x00533800): // add_all_outside_cells (added_outside-guarded) then the // building bridge for the landcell's building, if any. if (!outdoorAdded) { AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); outdoorAdded = true; } var building = cache.GetBuilding(cellId); if (building is not null) CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _); } } // Static prune (do_not_load_cells, 0052b66e): indoor-seeded // statics keep only {seed} ∪ seed.stab_list. if (isStatic && seedLow >= 0x0100u) { var seedCell = cache.GetCellStruct(seedCellId); if (seedCell is not null) { var keep = new List(candidates.Count); foreach (uint id in candidates.OrderedIds) { if (id == seedCellId || seedCell.VisibleCellIds.Contains(id)) keep.Add(id); } if (keep.Count != candidates.Count) { candidates.Clear(); foreach (uint id in keep) candidates.Add(id); } } } } return candidates.OrderedIds; } /// /// Verbatim port of CEnvCell::find_visible_child_cell /// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP /// point_in_cell contains , checking the /// start cell first (:311402), then — when is /// true (retail arg3 != 0, :311444) — the start's stab_list /// (), else (arg3 == 0, :311411) /// its direct portal neighbours. Returns 0 when no cell contains the point /// (retail return 0 at :311469). /// /// /// Sibling of (retail find_cell_list) — both /// resolve membership from the cell graph via . /// Used by CPhysicsObj::AdjustPosition (pc:280028, arg5 = 1 → /// stab-list mode) to seat the camera sweep's start cell at the head-pivot. /// /// /// /// acdream adaptation (matches at line 518): a cell /// with no hydrated cannot run /// point_in_cell, so it is treated as NOT containing the point (skipped), /// rather than letting 's null-node /// "inside" default make it spuriously claim every point. /// /// 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; } /// /// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point: /// transform to the cell's local frame, then . /// A cell with no hydrated returns false (see /// 's adaptation note). /// 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); } /// /// 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 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; // SEED (retail CObjCell::find_cell_list 0052b535-0052b56c): an indoor id // adds exactly the current cell at INDEX 0 (the current-cell-first pick // hysteresis that stops the flap); an outdoor id adds every landcell the // path spheres overlap (add_all_outside_cells, 0052b53f — which also // sets CELLARRAY.added_outside, hence outdoorAdded starts true there). bool outdoorAdded; if (currentLow >= 0x0100u) { var currentCell = cache.GetCellStruct(currentCellId); if (currentCell is null) return currentCellId; candidates.Add(currentCellId); outdoorAdded = false; } else { AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates); outdoorAdded = true; } // THE WALK — ONE forward pass over the GROWING array for EVERY seed, // mirroring retail's `for (i=0; ifind_transit_cells(...)` vtable dispatch (pseudo_c: // 308775-308785 / 0052b576-0052b5ab). CellArray.Add dedups, so the walk // terminates when no new cell is appended; read OrderedIds[i] by index // because the list grows under us. // // #112 ROOT CAUSE (2026-06-12, cottage-112-capture1.log): the outdoor // seed used to run CheckBuildingTransit over a landcell SNAPSHOT and // stop — building-admitted entry cells were never expanded, so a player // whose centre stood in a DEEP room (not building-portal-adjacent) // could never be promoted from an outdoor seed: the pick kept the // outdoor landcell while they walked the cottage interior (transparent // interior; promotion fired only on touching portal-adjacent 0x102's // own volume). Retail's single growing walk expands the admitted entry // cells to the deeper rooms the spheres overlap — ported below. for (int i = 0; i < candidates.Count; i++) { uint cellId = candidates.OrderedIds[i]; if ((cellId & 0xFFFFu) < 0x0100u) { // Landcell dispatch — CLandCell::find_transit_cells (0x00533800) // → CSortCell::find_transit_cells (0x00534060, this->building) // → CBuildingObj::find_building_transit_cells (0x006b5230) // → CEnvCell::check_building_transit (0x0052c5d0): the building // bridge admits the building's portal-adjacent ENTRY cells into // the same growing array; the walk then expands them via the // envcell dispatch below. var building = cache.GetBuilding(cellId); if (building is null) continue; CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _); continue; } var cell = cache.GetCellStruct(cellId); if (cell is null) continue; FindTransitCellsSphere( cache, cell, cellId, worldSpheres, sphereCount, candidates, out bool exitOutsideStraddle); // #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; // BR-7 / A6.P4 C4 (2026-06-11): outdoor cells enter the array // on the retail STRADDLE gate — |dist| < radius + F_EPSILON // against an exterior portal plane (CEnvCell::find_transit_cells // 0x0052c820; gate at 0052c9d6) — replacing the A6.P5 // hasExitPortal TOPOLOGY widening. Appended AFTER the interior // cells, matching retail order (add_all_outside_cells at the end, // pseudo_c:310120) — interior-wins is preserved. Once-per-walk via // outdoorAdded = retail CELLARRAY.added_outside (0x00533630). if (exitOutsideStraddle && !outdoorAdded) { AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates); outdoorAdded = true; } } 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 worldSpheres, int numSpheres) { if (numSpheres <= 0 || worldSpheres.Count == 0) return 0; return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count; } }