Brainstormed spec for the follow-up to Cluster A: port retail's portal-graph cell traversal to replace Phase D's AABB containment shortcut. Closes ISSUES.md #87 and the remaining wall-collision parts of #84 + #85 — indoor walking with walls that block from inside, walking through doors that updates CellId. Scope: all three transition types (indoor↔indoor, indoor↔outdoor, outdoor→indoor). AABB containment deleted entirely; portal traversal is the only path. Key data references: docs/research/acclient_indoor_transitions_pseudocode.md (2026-04-13) has the entire algorithm already documented from ACE source cross-referenced against the retail header. BSPQuery.PointInsideCellBsp is already wired (just unused). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
Indoor Portal-Based Cell Tracking — Design
Status: Brainstormed 2026-05-19. Awaiting user spec review before plan.
Scope: Port retail's portal-graph cell traversal to replace Phase D's AABB containment shortcut. Closes ISSUES.md #87 and the remaining wall-collision parts of #84 and #85 (indoor walking — walls don't block, walking through doors doesn't update CellId).
Predecessor: Cluster A (docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md) shipped 2026-05-19. Phase D's AABB containment was a deliberate shortcut that the capture log proved insufficient for normal indoor walking.
Retail pseudocode reference: docs/research/acclient_indoor_transitions_pseudocode.md (2026-04-13) — the entire algorithm is already documented from ACE source cross-referenced against the retail header. This spec is the porting plan, not a re-derivation.
1. What we know
The 2026-04-13 research doc enumerates:
CObjCell::find_cell_list— the top-level driver, called every movement tick. Builds the list of cells a sphere overlaps + identifies the new "current cell" via point-in-cell.CEnvCell::find_transit_cells(sphere variant) — walks portal neighbors of an indoor cell. Adds neighbor cells whosesphere_intersects_cellreturnsInsideorCrossing.CEnvCell::check_building_transit— the outdoor→indoor entry path, invoked fromBuildingObj::find_building_transit_cells.CLandCell::add_all_outside_cells— outdoor neighbor expansion on the 24m landcell grid.CCellStruct::point_in_cell→ tail-callsBSPTREE::point_inside_cell_bsp(cell_bsp, localPoint). Thecell_bspis a third BSP per cell, separate fromphysics_bspanddrawing_bsp.
acdream already has:
BSPQuery.PointInsideCellBsp(node, point)at src/AcDream.Core/Physics/BSPQuery.cs:940 — the canonical retail port ofpoint_inside_cell_bsp. Currently wired but unused.LoadedCell.Portals(inAcDream.App.Rendering) — populated fromenvCell.CellPortalsfor the visibility renderer. Used for portal-BFS visibility, not collision.PhysicsDataCache.CacheCellStructcachesCellStruct.PhysicsBSP(collision BSP) +PhysicsPolygons+VertexArray. Does NOT currently cacheCellStruct.CellBSPor portal data.
Capture evidence (launch-cluster-a-cache-diag3.log, launch-cluster-a-verify.log):
- Holtburg interior cells DO have full physics geometry (e.g.
0xA9B40143has 14 polys all resolved, AABB(-11.60, -1.60, 0.00) → (-6.20, 7.60, 2.80)). - Phase D's AABB containment fires for ~6 frames per session (mid-jump apex). The threshold/doorway cells with thin Z AABB (e.g.
0xA9B40146with AABB Z[-0.20, 0.00]) never capture a standing player. - Result: indoor cell-BSP collision branch fires intermittently; walls don't consistently block.
2. Goal
Port retail's portal-graph cell traversal so:
- The player's CellId tracks indoor cells correctly when walking inside a building.
- Walking through a doorway (portal) promotes/demotes CellId correctly.
- Walking into a building from outside (through a
BuildingObjportal) promotes CellId to the right interior cell. - The indoor cell-BSP collision branch fires every frame the player is in an indoor cell, so walls block consistently.
Out of scope:
- Visibility-side portal traversal (
CellVisibility/LoadedCell.Portals) — kept as-is. This phase is collision-side only. - Two-sphere parts/AABB variant of
find_transit_cells(used for creatures and large objects) — port only the player's single-sphere case for now. VisibleCellscleanup filter — the optional last step offind_cell_listthat strips invisible cells from the candidate set. Skip; the BSP-based point-in-cell already picks one winner.- Multi-step sub-tick portal crossings within a single movement step — retail handles fast movement that crosses multiple portals; we'll port the basic single-crossing case and revisit if regressions surface.
3. Architecture
Movement tick (per substep)
│
▼
PhysicsEngine.ResolveCellId(worldPos, currentCellId)
│
▼
╔═══════════════════════════════════════════════╗
║ CellTransit.FindCellList ║
║ ║
║ current is indoor (low >= 0x0100)? ║
║ yes ─► seed cellArray with current EnvCell ║
║ no ─► add_all_outside_cells (LandCell) ║
║ + check_building_transit hits ║
║ ║
║ for each cell in cellArray (BFS-like): ║
║ cell.find_transit_cells(sphere) ──► add ║
║ neighbours via portal-graph walk ║
║ ║
║ for each cell in cellArray: ║
║ if PointInsideCellBsp(cell.CellBSP, lpos): ║
║ ─► newCurrentCell = cell, break ║
╚═══════════════════════════════════════════════╝
│
▼
sp.CheckCellId = newCurrentCell.Id (full prefix)
│
▼
[indoor-bsp] probe fires correctly for indoor cells
Cell-BSP collision branch in FindEnvCollisions runs
The hot path runs once per FindEnvCollisions call. Portal-graph traversal walks the local neighborhood (current cell + 1-2 hops). Typical work per tick: ~5-10 BSP point tests, each O(BSP depth) ≈ O(log N). Cheaper than the current AABB scan over all loaded cells.
4. Components
4.1 Data types (extend / add)
CellPhysics (extended — same record/class as today):
| Field | Status | Source |
|---|---|---|
BSP |
existing | cellStruct.PhysicsBSP (collision) |
PhysicsPolygons |
existing | cellStruct.PhysicsPolygons |
Vertices |
existing | cellStruct.VertexArray |
WorldTransform |
existing | passed in from GameWindow |
InverseWorldTransform |
existing | computed |
Resolved |
existing | from ResolvePolygons |
LocalAabbMin / LocalAabbMax |
delete | Phase D AABB shortcut |
CellBSP |
add | cellStruct.CellBSP (third BSP for point-in-cell) |
Portals |
add | IReadOnlyList<PortalInfo> from envCell.CellPortals |
VisibleCellIds |
add (optional, deferred) | envCell.VisibleCells keys — for future cleanup filter; populated but unused in this phase |
PortalPolygons |
add | cellStruct.Polygons resolved by id (separate from PhysicsPolygons; portals reference visible polys) |
PortalInfo (new readonly struct in AcDream.Core.Physics):
public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)
{
/// <summary>Bit 2 of Flags. See research doc §"PortalSide flag semantics".</summary>
public bool PortalSide => (Flags & 2) == 0;
}
BuildingPhysics (new sealed class in AcDream.Core.Physics):
public sealed class BuildingPhysics
{
public required Matrix4x4 WorldTransform;
public required Matrix4x4 InverseWorldTransform;
public required IReadOnlyList<BldPortalInfo> Portals;
}
public readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch);
One BuildingPhysics per outdoor landcell that contains a building stab. Used for outdoor→indoor entry.
4.2 Caching (extend PhysicsDataCache)
CacheCellStruct(envCellId, cellStruct, worldTransform) — extended:
After the existing Resolved = ResolvePolygons(...) step, also populate the new fields:
CellBSP = cellStruct.CellBSP(verify field name during plan-writing; the DAT type may useCellBSP,CellBsp, or similar)Portals = envCell.CellPortals.Select(cp => new PortalInfo(cp.OtherCellId, cp.PolygonId, cp.Flags)).ToList(). Decision: changeCacheCellStruct's signature toCacheCellStruct(uint envCellId, EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)so portal data and otherEnvCell-side fields are available in a single atomic call. One call site (GameWindow.cs:5384); change is mechanical.VisibleCellIds = new HashSet<uint>(envCell.VisibleCells.Keys)— populated but unused in this phase.PortalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray)— same shape asResolvedbut built from the visible polygon table (since portalPolygonIdindexesPolygons, notPhysicsPolygons— confirmed inGameWindow.cs:5685).
CacheBuilding(landcellId, portals, buildingWorldTransform) — new:
Invoked from GameWindow.BuildInteriorEntitiesForStreaming for each landcell that contains a building stab. The DAT data shape (BldPortals from LandBlockInfo.Buildings) needs verification during plan-writing.
Deleted methods:
PhysicsDataCache.TryFindContainingCell— Phase D's AABB containment scan.- The AABB-compute block inside
CacheCellStruct.
4.3 CellTransit (new static class)
New file: src/AcDream.Core/Physics/CellTransit.cs. Pure-static, owns three public functions:
public static class CellTransit
{
/// <summary>
/// Top-level driver. Ported from retail CObjCell::find_cell_list (sphere variant).
/// Returns the cell id whose CellBSP contains the sphere center, or the original
/// fallback cell id if no cell matches.
/// </summary>
public static uint FindCellList(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out CellSet candidateSet);
/// <summary>
/// Indoor portal-neighbour expansion. Ported from CEnvCell::find_transit_cells
/// (sphere variant). For each portal of `currentCell`, tests whether the sphere
/// could overlap the neighbour cell and adds it to `candidateSet`.
/// </summary>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
ref CellSet candidateSet);
/// <summary>
/// Outdoor→indoor entry. Ported from BuildingObj::find_building_transit_cells +
/// CEnvCell::check_building_transit. For each BldPortal of `buildingPhysics`,
/// resolves the destination EnvCell and tests whether the sphere is inside it
/// via PointInsideCellBsp.
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics buildingPhysics,
Vector3 worldSphereCenter,
float sphereRadius,
ref CellSet candidateSet);
/// <summary>
/// Outdoor neighbour expansion. Ported from CLandCell::add_all_outside_cells.
/// Computes the player's 2D position within the 24×24m landcell and adds
/// neighbour landcells whose boundary the sphere crosses.
/// </summary>
public static void AddAllOutsideCells(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
ref CellSet candidateSet);
}
CellSet is a small helper — either HashSet<uint> or a thin wrapper allocating a stackalloc-backed list. Pick during plan-writing based on allocation profile.
4.4 PhysicsEngine.ResolveCellId (rename + rewrite)
Replaces PhysicsEngine.ResolveOutdoorCellId. New name + signature extended with a sphereRadius argument (needed by FindTransitCellsSphere for the sphere-vs-portal-plane test). Body becomes:
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
if (fallbackCellId == 0) return 0;
if (DataCache is null) return fallbackCellId;
uint newCellId = CellTransit.FindCellList(
DataCache,
worldPos,
sphereRadius,
currentCellId: fallbackCellId,
out _);
return newCellId != 0 ? newCellId : fallbackCellId;
}
The caller (Transition.FindEnvCollisions at TransitionTypes.cs:1181) has sp.GlobalSphere[0].Radius available and passes it through. The other two PhysicsEngine call sites (Resolve, ResolveWithTransition) need to plumb the sphere radius from their respective callers; the existing physics types carry it.
Three existing call sites of ResolveOutdoorCellId get renamed AND updated to pass the sphere radius:
PhysicsEngine.ResolveWithTransition(line ~729)PhysicsEngine.Resolve(line ~287)Transition.FindEnvCollisions(TransitionTypes.cs:1181)
4.5 Bootstrap on teleport
When the player teleports to a new cell (server-provided cell id from the network), the existing teleport path stores the cell id and triggers ResolveCellId on the next physics tick. Two cases:
- Server-provided cell id is loaded in our cache →
FindCellListstarts from that cell, walks the portal graph, point-in-cell determines the actual current cell. Works correctly. - Server-provided cell id is NOT yet loaded →
FindCellListfalls through toAddAllOutsideCells(treats as outdoor). The next tick after streaming loads the cell, the portal-graph walk picks it up.
Acceptance for teleport: player teleporting to an indoor cell (e.g. Holtburg cottage interior) gets the correct CellId on the first or second tick after spawn. Documented as a known edge case if the streaming takes more than one tick.
5. Data flow
Landblock load (one-time per landblock)
GameWindow.BuildInteriorEntitiesForStreaming(landblockId, lbInfo)
│
▼
For each EnvCell:
envCell = _dats.Get<EnvCell>(envCellId)
cellStruct = environment.Cells[envCell.CellStructure]
cellTransform = R(envCell.Position.Orientation) * T(envCell.Position.Origin + lbOffset + Z-bump)
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform)
│ populates: BSP, CellBSP, PhysicsPolygons, Vertices, WorldTransform,
│ InverseWorldTransform, Resolved, Portals, PortalPolygons,
│ VisibleCellIds
For each landcell containing a building (LandBlockInfo.Buildings):
_physicsDataCache.CacheBuilding(landcellId, building.Portals, buildingTransform)
│ populates: BldPortals list + buildingWorldTransform
Movement tick (per substep)
PhysicsEngine.ResolveWithTransition starts
│
▼
Transition.FindEnvCollisions:
sp.CheckCellId = ... (current cell estimate)
sphereRadius = sp.GlobalSphere[0].Radius
newCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId)
if newCellId != sp.CheckCellId:
sp.SetCheckPos(sp.CheckPos, newCellId)
│
▼
Cell-BSP branch fires if sp.CheckCellId & 0xFFFF >= 0x0100
├── BSPQuery.FindCollisions(cellPhysics.BSP, ...) ← walls collide here
└── [indoor-bsp] probe emits a log line
│
▼
Outdoor terrain collision (unchanged)
6. Commit shape (preview)
feat(physics): wire CellBSP + Portals + PortalPolygons into CellPhysics— extendCellPhysicsshape; updateCacheCellStructsignature to acceptenvCell(for portal data); deletesLocalAabbMin/Maxfields and the AABB compute. Tests verify a syntheticEnvCellwith portals + CellBSP populates the new fields correctly.feat(physics): port find_transit_cells sphere variant for indoor portals— newCellTransit.FindTransitCellsSphere. Tests use a synthetic two-cell portal pair to verify a sphere crossing the portal poly adds the neighbour cell.feat(physics): port BuildingPhysics + check_building_transit for outdoor→indoor—CacheBuilding+CellTransit.CheckBuildingTransit. GameWindow wiring at landblock load. Tests verify a sphere overlapping a building portal triggers indoor-cell add.feat(physics): port add_all_outside_cells for landcell neighbours—CellTransit.AddAllOutsideCells. Tests cover the 24×24m grid boundary cases.feat(physics): port find_cell_list driver, wire into ResolveCellId, delete AABB containment— top-level driver; renameResolveOutdoorCellId→ResolveCellIdand update 3 call sites; deletePhysicsDataCache.TryFindContainingCell. Rewrites the 4 Phase D tests (ResolveOutdoorCellIdIndoorContainmentTests) to use the portal traversal mechanism.- Capture session (user-driven) — walk the Holtburg cottage with
ACDREAM_PROBE_INDOOR_BSP=1+ACDREAM_PROBE_CELL=1+ACDREAM_PROBE_CELL_CACHE=1. Verify all four acceptance criteria below. docs(phase): Indoor portal cell tracking shipped— closes #87 and the remaining wall-collision parts of #84 + #85; updates ISSUES.md, roadmap, CLAUDE.md; writes shipped-handoff doc.
7. Files touched
Modified:
src/AcDream.Core/Physics/PhysicsDataCache.cs—CellPhysicsshape extended;CacheCellStructsignature change; newCacheBuilding; deletedTryFindContainingCell+ AABB compute.src/AcDream.Core/Physics/PhysicsEngine.cs— renameResolveOutdoorCellId→ResolveCellId; body rewritten to callCellTransit.FindCellList; 3 call sites in this file updated.src/AcDream.Core/Physics/TransitionTypes.cs— call site update at line 1181.src/AcDream.App/Rendering/GameWindow.cs— passenvCellinto the extendedCacheCellStruct; wireCacheBuildingat landblock load.
New:
src/AcDream.Core/Physics/CellTransit.cs— the new static class withFindCellList,FindTransitCellsSphere,CheckBuildingTransit,AddAllOutsideCells.tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs— indoor portal traversal.tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs— outdoor→indoor entry.tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs— outdoor neighbours.tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs— integration tests.
Rewritten:
tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs— renamed and ported to test the portal-based replacement.
Closed in ISSUES.md:
- #87 (indoor cell tracking via AABB containment) — fully closed by this phase.
- #85 (pass through walls outside→in) — closed; the outdoor→indoor entry path through
BuildingObjhandles this. - #84 (blocked by air indoors) — the wall-pass-through portion that remained after Phase D is closed here.
8. Error handling
- Cell loaded without
CellBSP—PointInsideCellBsp(null, pt)per its current contract returnstrue, which over-matches. Add an explicitcellPhysics.CellBSP?.Root == nullskip inFindTransitCellsSphereand inFindCellList's containment loop. The cell is treated as "not findable" until its BSP loads. - Portal references an unloaded
OtherCellId— retail handles this with a "load hint" path that adds a null-cell entry for the streamer. We skip the add and continue; the next physics tick after streaming loads the cell picks it up. Document the one-tick latency as a known edge case. - Player teleports to a cell ID with no cached
CellPhysics— fall back toAddAllOutsideCells(treat as outdoor) for that tick; the next tick after streaming loads the cell, portal traversal takes over. - No try/catch swallows. If the BSP traversal hits a malformed tree, the underlying
BSPQueryasserts (Debug) or returnsfalse(Release).
9. Testing
Unit tests (per commit)
CellPhysicsCellBspWiringTests—CacheCellStructpopulatesCellBSP,Portals,PortalPolygons,VisibleCellIds.CellTransitFindTransitCellsSphereTests— synthetic two-cell portal pair:- Sphere overlapping portal poly → adds neighbour.
- Sphere far from portal → doesn't add neighbour.
- Sphere on wrong side of portal (per
PortalSide) → doesn't add neighbour. - Sphere crossing exit portal (
OtherCellId == 0xFFFF) → setscheckOutside = true.
CellTransitCheckBuildingTransitTests— outdoor sphere overlapping building portal plane + inside destination cell's CellBSP → adds the indoor cell.CellTransitAddAllOutsideCellsTests— sphere at boundary X+Y, +X−Y, −X+Y, −X−Y of a 24m cell → 1, 2, or 4 cells in the result set.CellTransitFindCellListTests— integration:- Indoor seed → returns matching indoor cell after portal walk.
- Outdoor seed → returns matching landcell.
- Outdoor seed near building portal → returns indoor cell via
check_building_transit. - Indoor seed crossing exit portal → returns outdoor landcell.
Rewritten tests
- The four
ResolveOutdoorCellIdIndoorContainmentTests(Phase D) — same scenarios, but using the portal-traversal mechanism rather than synthetic AABB-only cells. Some may merge withCellTransitFindCellListTests.
Live test (user-driven)
Same launch incantation as Phase E:
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
$env:ACDREAM_DEVTOOLS = "1"
Walk the Holtburg cottage end-to-end. Verify all four acceptance criteria below.
10. Acceptance
- Indoor walking — Player walks inside the Holtburg cottage freely; walls block from inside (current bug fixed); furniture still collides (no regression from per-object collision).
- Outdoor→indoor — Player walks toward the cottage door from outside; CellId promotes to an indoor cell when crossing the doorway; walls beyond the door block.
- Indoor→outdoor — Player walks back out through the door; CellId demotes to the outdoor landcell; outdoor terrain collision resumes; ACE doesn't report cell-state desync.
- Indoor→indoor — Player walks from one room to another through an interior doorway; CellId transitions correctly between EnvCells; no momentary "stuck on portal plane" issues.
[indoor-bsp]probe fires consistently during indoor walking — not just during jumps (the Phase D failure mode).dotnet build+dotnet testgreen with the new test suite. Pre-existing baseline of 8 failures unchanged.
11. Out of scope (deferred / explicit non-goals)
- Parts/AABB variant of
find_transit_cells— used for creatures and large objects with multi-part bounding boxes. Only the player's single-sphere case is in scope here; the AABB variant ports as a follow-up if remote-entity cell tracking proves broken. VisibleCellscleanup filter — the optional last step offind_cell_listthat strips invisible cells from the candidate set. Skipped; the BSP point-in-cell already picks one winner. Data is populated for future use.- Multi-portal crossings within a single movement step — retail's resolver handles fast movement crossing multiple portals via the per-substep loop. We rely on the per-substep loop being fine-grained enough; if a regression surfaces, address as a follow-up.
- Unification with
LoadedCell.PortalsinAcDream.App.Rendering— two parallel portal stores remain (Core for collision, App for visibility). Future cleanup could unify them, but not in this phase. CellTransitfor moving entities other than the player — the function works for any sphere, but only the player's resolve path is wired this phase. Remote-entity cell tracking remains as-is.
12. Risks
- DAT field name mismatch. The pseudocode doc references
CellStruct.CellBSPbut DatReaderWriter may name it differently (e.g.cell_bsp,CellBsp,CellTree). Verify at plan-writing time by reading DatReaderWriter'sCellStruct.cs(NuGet source). If the field is missing entirely, file a sub-phase to extend DatReaderWriter — but this is unlikely given the dat format includes the BSP. BuildingObj.Portalsstructure differs from indoor portals. Retail'sBldPortalhas more fields (OtherPortalId,ExactMatch). The DAT representation lives underLandBlockInfo.Buildings[...]; verify the field shape at plan-writing time.- Sphere radius plumbing.
FindTransitCellsSphereneeds the player's sphere radius to test against the portal plane. The caller (Transition.FindEnvCollisions) has access viasp.GlobalSphere[0].Radius; plumb it throughResolveCellId's signature in the same commit that wires the call. - Rename cost. Renaming
ResolveOutdoorCellId→ResolveCellIdcascades through 4 call sites + test names + commit messages. Bundling the rename with the wiring commit keeps the change atomic; spreading it across commits creates a transient state where the function name doesn't match its behavior. - Phase D test rewrites. The 4 Phase D tests assert AABB-containment behavior that no longer exists. Rewriting them to use the portal-traversal mechanism requires synthetic test fixtures with portals + CellBSP — more setup boilerplate. Acceptable cost; integration coverage improves.
13. Phase name + roadmap placement
Proposed name: "Indoor portal-based cell tracking" (sometimes abbreviated "Indoor walking Phase 2" since it follows Cluster A / Indoor walking Phase 1).
Roadmap placement: add to docs/plans/2026-04-11-roadmap.md ahead-table as the next item in the indoor track. Sits in front of any remaining indoor-rendering polish (issues #78, #79-#82) since indoor walking is the gating issue.
Milestone: still parallel to M2 (Kill a drudge). Completing indoor walking unblocks demos that involve buildings (e.g. talking to interior NPCs, picking up items from inside shops).