11-task plan: data wiring (PortalInfo + extend CellPhysics + extend CacheCellStruct) → CellTransit port (FindTransitCellsSphere + AddAllOutsideCells + FindCellList) → ResolveCellId integration (rename + plumb sphereRadius + delete AABB containment) → BuildingPhysics for outdoor→indoor → capture + docs. Task 0 verifies DatReaderWriter exposes CellStruct.CellBSP and LandBlockInfo.Buildings before any code touches them. The CellBSP property name is the one known unknown. Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 KiB
Indoor Portal-Based Cell Tracking Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Phase D's AABB-based indoor cell tracking with retail-faithful portal-graph traversal, so walls block consistently inside buildings and CellId updates correctly when crossing doors (closes ISSUES.md #87 + remaining wall-collision parts of #84 + #85).
Architecture: A new pure-static CellTransit class in AcDream.Core.Physics ports retail's four functions: find_cell_list (top-level driver), find_transit_cells sphere variant (indoor portal walk), check_building_transit (outdoor→indoor entry), and add_all_outside_cells (outdoor neighbour expansion). Cell containment uses the already-ported BSPQuery.PointInsideCellBsp against each cell's CellBSP (a third BSP tree per cell, separate from PhysicsBSP and DrawingBSP). The existing PhysicsEngine.ResolveOutdoorCellId is renamed to ResolveCellId, its body rewritten to call CellTransit.FindCellList, and Phase D's TryFindContainingCell + AABB fields are deleted entirely.
Tech Stack: C# / .NET 10, xUnit, DatReaderWriter.DBObjs.EnvCell + DatReaderWriter.DBObjs.LandBlockInfo for portal data sources, existing BSPQuery.PointInsideCellBsp for point-in-cell tests.
Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Research: docs/research/acclient_indoor_transitions_pseudocode.md — the full algorithm in pseudocode, cross-referenced ACE + retail decomp.
File Structure
| File | Action | Responsibility |
|---|---|---|
src/AcDream.Core/Physics/PortalInfo.cs |
create | readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags) with PortalSide property ((Flags & 2) == 0). |
src/AcDream.Core/Physics/BuildingPhysics.cs |
create | sealed class BuildingPhysics holding WorldTransform, InverseWorldTransform, and an IReadOnlyList<BldPortalInfo> for outdoor→indoor entry. Plus readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch). |
src/AcDream.Core/Physics/CellTransit.cs |
create | Static class with the four retail-ported functions: FindCellList, FindTransitCellsSphere, CheckBuildingTransit, AddAllOutsideCells. |
src/AcDream.Core/Physics/PhysicsDataCache.cs |
modify | Extend CellPhysics with CellBSP, Portals, PortalPolygons, VisibleCellIds. Delete LocalAabbMin/Max. Change CacheCellStruct signature to accept EnvCell envCell. Delete TryFindContainingCell. Add CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo>, Matrix4x4) and GetBuilding(uint). |
src/AcDream.Core/Physics/PhysicsEngine.cs |
modify | Rename ResolveOutdoorCellId → ResolveCellId. Add sphereRadius parameter. Body becomes CellTransit.FindCellList call. Update 2 internal callers. |
src/AcDream.Core/Physics/TransitionTypes.cs |
modify | Update call at line 1181 to pass sphere radius. |
src/AcDream.App/Rendering/GameWindow.cs |
modify | Update CacheCellStruct call at line 5384 to pass the EnvCell. Add CacheBuilding call inside the lbInfo.Buildings loop. |
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs |
create | PortalSide flag-decoding tests. |
tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs |
create | Verify CacheCellStruct populates CellBSP, Portals, PortalPolygons, VisibleCellIds. |
tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs |
create | Indoor portal-graph walk: sphere overlaps portal → adds neighbour; far → doesn't add; wrong side → doesn't add; exit portal → marks checkOutside. |
tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs |
create | Outdoor sphere overlapping building portal → adds indoor cell. |
tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs |
create | Sphere at 24m landcell boundary edges → correct neighbour set. |
tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs |
create | Integration: indoor → matching indoor cell; outdoor → matching landcell; outdoor near building → entering indoor cell; indoor → outdoor through exit portal. |
tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs |
rename + rewrite | Rename file to ResolveCellIdTests.cs; the 4 Phase D AABB tests are ported to use synthetic portal+CellBSP fixtures. |
Plan-Time Reference Facts
These are facts an implementer needs and should NOT have to rediscover.
Fact 1. BSPQuery.PointInsideCellBsp(node, point) lives at src/AcDream.Core/Physics/BSPQuery.cs:940. Signature: public static bool PointInsideCellBsp(PhysicsBSPNode? node, Vector3 point). Returns true for null node (treats it as fully solid — must NOT be reached by FindCellList; callers must guard cellPhysics.CellBSP?.Root == null themselves).
Fact 2. The existing CellPhysics is a sealed class (not record) with required + init-only properties. New fields can be added as non-required with defaults. Source: PhysicsDataCache.cs:422.
Fact 3. envCell.CellPortals (from DatReaderWriter.DBObjs.EnvCell) is an iterable where each portal has .OtherCellId (ushort low-16), .PolygonId (ushort), and .Flags (some integer type — cast to ushort). Confirmed by GameWindow.cs:5506-5511. The portal's PolygonId indexes cellStruct.Polygons (visible polys), NOT cellStruct.PhysicsPolygons. Confirmed by GameWindow.cs:5685-5689 comment.
Fact 4. cellStruct.CellBSP field name verification is Task 0 (first task — must complete before any code changes). The DAT format definitely has this BSP per the retail header and pseudocode doc, but DatReaderWriter's exact C# property name needs confirming. If it's missing from the C# binding, escalate to controller.
Fact 5. LandBlockInfo.Buildings (from DatReaderWriter.DBObjs.LandBlockInfo) carries the building portal data — each Building has a portal list. The exact C# property names need confirming in Task 0 as well.
Fact 6. Existing ResolvePolygons(polys, vertexArray) helper at PhysicsDataCache.cs lines 231-275 is reusable for both PhysicsPolygons and Polygons (visible). The function is private static; either widen visibility (recommended) OR copy/paste the logic into a public wrapper.
Fact 7. The 3 ResolveOutdoorCellId call sites are:
PhysicsEngine.cs:254(definition itself)PhysicsEngine.cs:755(insideResolveWithTransition)PhysicsEngine.cs:773(insideResolveWithTransitionfallback path)TransitionTypes.cs:1181(insideTransition.FindEnvCollisions)
Plus 2 test references in tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs (the Phase D tests).
Task 0: Verify DatReaderWriter field names (read-only)
Files: Read-only investigation. No commits.
- Step 1: Confirm
CellStruct.CellBSPexists
Run from the worktree root:
$cellStruct = [DatReaderWriter.Types.CellStruct]
$cellStruct.GetProperties() | Select-Object Name, PropertyType | Where-Object { $_.Name -match 'BSP|Bsp|Polygon|Portal|Vertex' }
OR find the DatReaderWriter source on disk:
find ~/.nuget/packages/datreaderwriter -name "*.cs" 2>/dev/null | head -5
find ~/.nuget/packages/datreader* -name "CellStruct*" 2>/dev/null
OR find the type by listing all instance properties at the call site. Add a temporary line to GameWindow.cs around line 5660 (where cellStruct is in scope):
Console.WriteLine(string.Join(",", typeof(DatReaderWriter.Types.CellStruct).GetProperties().Select(p => p.Name)));
Build + run (don't commit). Read output. Remove the line.
Expected: a property name matching CellBSP / CellBsp (or similar). If found, note the EXACT name for Task 2.
- Step 2: Confirm
LandBlockInfo.Buildingsshape
Same method as Step 1, but for DatReaderWriter.DBObjs.LandBlockInfo. Look for Buildings (List or array). Inspect one Building's properties to find:
- A list of portals (probably
PortalsorBldPortals) - The building's world transform (probably
TransformorFrame)
For each Portal in a Building, find:
OtherCellId(uint or ushort)OtherPortalId(ushort)Flags(ushort or some flags type)- Optionally:
ExactMatch(bool)
Document the exact property names. If a field is named differently than the spec assumed, note the mapping.
- Step 3: Report findings
Note (mentally or in scratch) the EXACT C# property names that will be used in subsequent tasks:
cellStruct.<CellBSP-name>for the third BSPlbInfo.Buildings(or whatever name)- Building's portal list property
- Building's transform property
- BldPortal field names
If CellBSP is missing OR Buildings is missing OR the shape is fundamentally different, STOP and report BLOCKED. The plan assumes both exist — if they don't, the controller needs to escalate (e.g., extend DatReaderWriter upstream).
Throughout the rest of this plan, the placeholder <CellBSP> refers to whatever the actual C# property name turned out to be. Adjust each task accordingly.
Task 1: Add PortalInfo struct (TDD)
Files:
-
Create:
src/AcDream.Core/Physics/PortalInfo.cs -
Create:
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs -
Step 1: Write the failing test
Create tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs:
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class PortalInfoTests
{
[Fact]
public void PortalSide_FlagsBit2Clear_ReturnsTrue()
{
// (Flags & 2) == 0 → PortalSide is true.
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
Assert.True(portal.PortalSide);
}
[Fact]
public void PortalSide_FlagsBit2Set_ReturnsFalse()
{
// (Flags & 2) != 0 → PortalSide is false.
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 2);
Assert.False(portal.PortalSide);
}
[Fact]
public void PortalSide_OtherBitsSet_FollowsOnlyBit2()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0xFF & ~2);
Assert.True(portal.PortalSide);
}
[Fact]
public void OtherCellId_StoredAsLowSixteenBits()
{
// OtherCellId is a low-16 cell index (or 0xFFFF for exit-to-outdoor).
var portal = new PortalInfo(otherCellId: 0xFFFF, polygonId: 5, flags: 0);
Assert.Equal((ushort)0xFFFF, portal.OtherCellId);
}
}
- Step 2: Run, expect failure
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PortalInfo"
Expected: build fails (type not found).
- Step 3: Create the type
Create src/AcDream.Core/Physics/PortalInfo.cs:
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
/// EnvCells. Each <see cref="CellPhysics"/> carries a list of these,
/// mirroring retail's <c>CCellStruct.portals</c> array.
///
/// <para>
/// <see cref="OtherCellId"/> is a low-16 cell index (combined with the
/// owning landblock prefix at lookup time) or <c>0xFFFF</c> to mean
/// "exit to outdoor world" (the player crosses this portal to leave
/// the building).
/// </para>
///
/// <para>
/// <see cref="PolygonId"/> indexes the OWNING cell's
/// <see cref="CellPhysics.PortalPolygons"/> dict (the visible-polygon
/// table, NOT <see cref="CellPhysics.Resolved"/> which holds physics
/// polys).
/// </para>
///
/// <para>
/// <see cref="PortalSide"/> decodes bit 2 of <see cref="Flags"/>:
/// <c>(Flags & 2) == 0</c> → portal's polygon normal points INTO
/// the owning cell (so dist > 0 in cell-local space means "outside
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
/// load-hint path for unloaded neighbours.
/// </para>
/// </summary>
public readonly struct PortalInfo
{
public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
{
OtherCellId = otherCellId;
PolygonId = polygonId;
Flags = flags;
}
public ushort OtherCellId { get; }
public ushort PolygonId { get; }
public ushort Flags { get; }
/// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
public bool PortalSide => (Flags & 2) == 0;
}
- Step 4: Run tests, expect green
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PortalInfo"
Expected: 4 tests passing.
- Step 5: No commit yet — bundle with Task 2 + Task 3 into one data-wiring commit.
Task 2: Extend CellPhysics with portal fields (TDD)
Files:
-
Modify:
src/AcDream.Core/Physics/PhysicsDataCache.cs(theCellPhysicsclass around line 422) -
Create:
tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs -
Step 1: Locate
CellPhysicsand confirm shape
Open src/AcDream.Core/Physics/PhysicsDataCache.cs and locate the CellPhysics class around line 422. Confirm:
-
It's a
sealed classwithrequired+init-only properties. -
It currently has
BSP,PhysicsPolygons,Vertices,WorldTransform,InverseWorldTransform,Resolved,LocalAabbMin,LocalAabbMax. -
Step 2: Add new fields, delete AABB fields
Replace the class body to:
- ADD:
CellBSP(PhysicsBSPTree?, init-only, nullable). - ADD:
Portals(IReadOnlyList<PortalInfo>, init-only, default empty). - ADD:
PortalPolygons(Dictionary<ushort, ResolvedPolygon>?, init-only, default null). - ADD:
VisibleCellIds(IReadOnlySet<uint>, init-only, default empty). - DELETE:
LocalAabbMinandLocalAabbMax(and their XML docs).
Concrete edit shape (the existing class continues to start with public sealed class CellPhysics { ... }):
public sealed class CellPhysics
{
// ── Pre-existing fields (unchanged) ────────────────────────────────
public PhysicsBSPTree? BSP { get; init; }
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
public VertexArray? Vertices { get; init; }
public required Matrix4x4 WorldTransform { get; init; }
public required Matrix4x4 InverseWorldTransform { get; init; }
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.<CellBSP-property-name></c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// </summary>
public PhysicsBSPTree? CellBSP { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
/// </summary>
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
/// <summary>
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
/// holds <c>PhysicsPolygons</c>. Portal lookup via
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
/// Nullable when the cell has no visible polys (rare).
/// </summary>
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
/// <summary>
/// The cell ids visible from this cell (low-16 indexes, combined
/// with the owning landblock prefix). Populated from
/// <c>envCell.VisibleCells</c>. Unused this phase; reserved for the
/// optional <c>find_cell_list</c> visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
}
Important: the AABB fields (LocalAabbMin, LocalAabbMax) are DELETED in this edit. Their XML docs go too. Any references to them anywhere in the codebase will fail the build in Task 7 (intentional — the build break shows you missed a deletion site).
- Step 3: Write the parity test (RED first)
Create tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs:
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellPhysicsPortalWiringTests
{
[Fact]
public void NewFields_HaveSensibleDefaults()
{
// Phase 2 added CellBSP / Portals / PortalPolygons / VisibleCellIds.
// Default-initialized values must not crash callers.
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
};
Assert.Null(cp.CellBSP);
Assert.Empty(cp.Portals);
Assert.Null(cp.PortalPolygons);
Assert.Empty(cp.VisibleCellIds);
}
[Fact]
public void NewFields_AcceptInitValues()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
Portals = new[] { portal },
VisibleCellIds = new System.Collections.Generic.HashSet<uint> { 0xA9B40101 },
};
Assert.Single(cp.Portals);
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
Assert.Contains(0xA9B40101u, cp.VisibleCellIds);
}
}
- Step 4: Run tests, expect green
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellPhysicsPortalWiring"
Expected: 2 tests passing.
- Step 5: Verify build is still otherwise green (deletion didn't break anything yet)
Run: dotnet build src/AcDream.Core/AcDream.Core.csproj
If references to LocalAabbMin / LocalAabbMax exist outside the deleted block, you'll see compile errors. Those error locations are EXACTLY what you must fix in Task 7. For now, the AcDream.Core.csproj might not build green yet — that's expected. Note the error locations.
Expected build state: may have errors referencing the deleted AABB fields. That's fine; Task 7 closes them.
- Step 6: No commit yet — Task 3 finishes the data wiring.
Task 3: Extend CacheCellStruct to populate portal data (TDD)
Files:
-
Modify:
src/AcDream.Core/Physics/PhysicsDataCache.cs(theCacheCellStructmethod around lines 131-180) -
Modify:
src/AcDream.App/Rendering/GameWindow.cs(call site at line 5384) -
Extend:
tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs -
Step 1: Replace
ResolvePolygonsvisibility
In src/AcDream.Core/Physics/PhysicsDataCache.cs, find the existing private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(...) at line 231. Change private to internal. The function stays as-is otherwise — same signature, same body. This makes it reusable for both PhysicsPolygons and the new PortalPolygons resolution.
- Step 2: Change
CacheCellStructsignature
Find the existing CacheCellStruct method at PhysicsDataCache.cs:131. Change the signature from:
public void CacheCellStruct(uint envCellId, CellStruct cellStruct, Matrix4x4 worldTransform)
to:
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)
Add the using DatReaderWriter.DBObjs; at the top of the file if not already there.
- Step 3: Replace the body
Inside CacheCellStruct, replace the body. New body:
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct, Matrix4x4 worldTransform)
{
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
Matrix4x4.Invert(worldTransform, out var inverseTransform);
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
// Portal list from envCell.CellPortals.
var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
foreach (var p in envCell.CellPortals)
{
portals.Add(new PortalInfo(
otherCellId: p.OtherCellId,
polygonId: p.PolygonId,
flags: (ushort)p.Flags));
}
// VisibleCells set — populated for future use; not consulted this phase.
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
if (envCell.VisibleCells is not null)
{
uint lbPrefix = envCellId & 0xFFFF0000u;
foreach (var lowId in envCell.VisibleCells.Keys)
visibleCellIds.Add(lbPrefix | lowId);
}
_cellStruct[envCellId] = new CellPhysics
{
BSP = cellStruct.PhysicsBSP,
PhysicsPolygons = cellStruct.PhysicsPolygons,
Vertices = cellStruct.VertexArray,
WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform,
Resolved = resolved,
// ── Phase 2 portal fields ──
CellBSP = cellStruct.<CellBSP-property-name>, // ← REPLACE with actual property name from Task 0
Portals = portals,
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
};
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
{
var root = cellStruct.PhysicsBSP?.Root;
int bspRootPolyCount = root?.Polygons?.Count ?? 0;
bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
// Recursive walk: count total leaf poly references + how many of
// those poly IDs are absent from the resolved dict. If
// bspTotalLeafPolys == 0 the BSP has no collidable polys at all.
int bspTotalLeafPolys = 0;
int bspUnmatchedIds = 0;
if (root is not null)
{
var stack = new System.Collections.Generic.Stack<DatReaderWriter.Types.PhysicsBSPNode>();
stack.Push(root);
while (stack.Count > 0)
{
var n = stack.Pop();
if (n.Polygons is not null)
{
foreach (var pid in n.Polygons)
{
bspTotalLeafPolys++;
if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
}
}
if (n.PosNode is not null) stack.Push(n.PosNode);
if (n.NegNode is not null) stack.Push(n.NegNode);
}
}
var bs = root?.BoundingSphere;
string bsStr = bs is null
? "bsphere=n/a"
: System.FormattableString.Invariant(
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
// Phase 2: dropped aabbMin/aabbMax (deleted in Task 2). Added
// portal/visible counts.
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-cache] envCellId=0x{envCellId:X8} " +
$"physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} " +
$"resolvedCount={resolved.Count} " +
$"bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} " +
$"{bsStr} " +
$"portalCount={portals.Count} " +
$"visibleCells={visibleCellIds.Count} " +
$"cellBspRoot={(cellStruct.<CellBSP-property-name>?.Root is null ? "null" : "ok")} " +
$"worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
}
}
Substitution: <CellBSP-property-name> appears twice in the snippet above — replace BOTH with the actual property name discovered in Task 0 (likely CellBSP or CellBsp).
- Step 4: Update the GameWindow call site
In src/AcDream.App/Rendering/GameWindow.cs at line 5384, find the existing call:
_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
Replace with:
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform);
(The envCell variable is already in scope at this site — it's the loop variable from the surrounding foreach over EnvCells.)
- Step 5: Extend the parity test with a portal-population check
Append to tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs:
[Fact]
public void CellPhysics_PortalsRoundTrip()
{
// Two portals: one indoor (OtherCellId=0x0101), one exit (OtherCellId=0xFFFF).
var portals = new[]
{
new PortalInfo(otherCellId: 0x0101, polygonId: 7, flags: 0),
new PortalInfo(otherCellId: 0xFFFF, polygonId: 8, flags: 2),
};
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
Portals = portals,
};
Assert.Equal(2, cp.Portals.Count);
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
Assert.True(cp.Portals[0].PortalSide);
Assert.Equal((ushort)0xFFFF, cp.Portals[1].OtherCellId);
Assert.False(cp.Portals[1].PortalSide);
}
- Step 6: Build + test green
Run: dotnet build && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellPhysicsPortalWiring|FullyQualifiedName~PortalInfo"
Expected: 7 tests passing (4 PortalInfo + 3 CellPhysicsPortalWiring). The full dotnet build may still have errors from TryFindContainingCell and AABB references that Task 7 cleans up — that's expected. The targeted filter must pass.
- Step 7: Commit (data wiring)
git add src/AcDream.Core/Physics/PortalInfo.cs `
src/AcDream.Core/Physics/PhysicsDataCache.cs `
src/AcDream.App/Rendering/GameWindow.cs `
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs `
tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs
git commit -m "$(cat <<'EOF'
feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics
Adds PortalInfo struct and extends CellPhysics with CellBSP (third BSP for
point-in-cell tests), Portals (from envCell.CellPortals), PortalPolygons
(resolved cellStruct.Polygons — portals reference visible polys, not
PhysicsPolygons), and VisibleCellIds (populated for future use). Deletes
the Phase D LocalAabbMin/Max fields; CacheCellStruct's AABB compute is
gone.
Build is intentionally not green yet — references to the deleted AABB
fields in PhysicsEngine and tests will be removed in the integration commit
that wires CellTransit.FindCellList. This data-wiring step lands first so
the new infrastructure is in place before the consumer.
Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: Port CellTransit.FindTransitCellsSphere (indoor portal walk, TDD)
Files:
-
Create:
src/AcDream.Core/Physics/CellTransit.cs -
Create:
tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs -
Step 1: Write the failing tests
Create tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs:
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitFindTransitCellsSphereTests
{
// Synthetic 2-cell scenario:
// Cell A at world origin; cell B 5m east of A.
// Portal poly at x=2.5 (the wall between A and B), normal pointing +X (out of A).
// Both cells are 5x5x5 m AABB.
private const float EPSILON = 0.02f;
private static (CellPhysics cellA, CellPhysics cellB) MakeAdjacentCells()
{
// Cell A: at world origin; portal poly at local x=2.5 (right wall), normal +X.
var portalPolyA = new ResolvedPolygon
{
Vertices = new[]
{
new Vector3(2.5f, -2.5f, 0f),
new Vector3(2.5f, 2.5f, 0f),
new Vector3(2.5f, 2.5f, 5f),
new Vector3(2.5f, -2.5f, 5f),
},
Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5
NumPoints = 4,
SidesType = DatReaderWriter.Enums.CullMode.None,
};
var cellA = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [10] = portalPolyA },
Portals = new[]
{
// Portal to Cell B (low-16 = 0x0101). Flags=0 → PortalSide=true.
new PortalInfo(otherCellId: 0x0101, polygonId: 10, flags: 0),
},
};
// Cell B: 5m east in world; same portal polygon mirrored.
var bWorldT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
Matrix4x4.Invert(bWorldT, out var bInvT);
var cellB = new CellPhysics
{
WorldTransform = bWorldT,
InverseWorldTransform = bInvT,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
// Cell B's CellBSP — null means point-in-cell returns "outside" for the test path.
// For these tests we don't need the BSP since we're testing the load-hint and
// add-by-sphere paths.
};
return (cellA, cellB);
}
[Fact]
public void SphereInsideCellA_NearPortal_AddsCellB()
{
var (cellA, cellB) = MakeAdjacentCells();
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
// Sphere center near the portal plane (local x=2.0, sphere radius=0.5).
// Sphere reaches x=2.5 which is the portal plane → straddles.
var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
float sphereRadius = 0.5f;
var candidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache,
currentCell: cellA,
currentCellId: 0xA9B40100u,
worldSphereCenter,
sphereRadius,
candidates,
out bool exitOutside);
Assert.Contains(0xA9B40101u, candidates);
Assert.False(exitOutside);
}
[Fact]
public void SphereInsideCellA_FarFromPortal_DoesNotAddCellB()
{
var (cellA, cellB) = MakeAdjacentCells();
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
// Sphere far from portal (local x=-1.0, sphere radius=0.5).
// Sphere reach extends only to x=-0.5 — nowhere near portal at x=2.5.
var worldSphereCenter = new Vector3(-1.0f, 0f, 2.5f);
var candidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache,
currentCell: cellA,
currentCellId: 0xA9B40100u,
worldSphereCenter,
sphereRadius: 0.5f,
candidates,
out bool exitOutside);
Assert.DoesNotContain(0xA9B40101u, candidates);
}
[Fact]
public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside()
{
// Modify cell A to have its second portal be an EXIT (OtherCellId = 0xFFFF).
var (cellA, _) = MakeAdjacentCells();
var exitOnly = new CellPhysics
{
WorldTransform = cellA.WorldTransform,
InverseWorldTransform = cellA.InverseWorldTransform,
Resolved = cellA.Resolved,
PortalPolygons = cellA.PortalPolygons,
Portals = new[]
{
new PortalInfo(otherCellId: 0xFFFF, polygonId: 10, flags: 0),
},
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, exitOnly);
var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
var candidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache,
currentCell: exitOnly,
currentCellId: 0xA9B40100u,
worldSphereCenter,
sphereRadius: 0.5f,
candidates,
out bool exitOutside);
Assert.True(exitOutside);
}
}
- Step 2: Run, expect failure
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindTransitCellsSphere"
Expected: build fails (CellTransit not found).
- Step 3: Implement
CellTransit.FindTransitCellsSphere
Create src/AcDream.Core/Physics/CellTransit.cs:
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 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>
/// <param name="cache">The physics data cache (for neighbour lookups).</param>
/// <param name="currentCell">The cell whose portals are walked.</param>
/// <param name="currentCellId">The full id (with landblock prefix) of
/// <paramref name="currentCell"/>. Used to resolve neighbour ids by
/// combining the prefix with <see cref="PortalInfo.OtherCellId"/>.</param>
/// <param name="worldSphereCenter">Player's foot-sphere center in world space.</param>
/// <param name="sphereRadius">Player's foot-sphere radius.</param>
/// <param name="candidates">Set to add neighbour cell ids to.</param>
/// <param name="exitOutside">Set to true if the sphere straddles an
/// exit portal (<c>OtherCellId == 0xFFFF</c>) — the caller should
/// then expand outdoor neighbour cells via <see cref="AddAllOutsideCells"/>.</param>
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;
// Don't break — there may be more portals to check.
}
continue;
}
uint otherId = lbPrefix | portal.OtherCellId;
var otherCell = cache.GetCellStruct(otherId);
if (otherCell is not null)
{
// Neighbour is loaded. Test containment via PointInsideCellBsp
// in the OTHER cell's local space.
var otherLocal = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
// We don't yet have sphere_intersects_cell ported. Use the
// load-hint sphere-plane heuristic from the research doc's
// "otherCell == null" branch as a conservative add — once
// the sphere is near the portal plane and on the "exit" side
// (per PortalSide), the neighbour is a valid candidate.
if (portal.PortalSide ? dist > -rad : dist < rad)
{
candidates.Add(otherId);
}
}
else
{
// Load-hint path: neighbour not yet cached. Mirrors retail's
// load-hint branch — add by plane-side test only.
if (portal.PortalSide ? dist > -rad : dist < rad)
{
candidates.Add(otherId);
}
}
}
}
}
Note on sphere_intersects_cell: retail's algorithm uses CellBSP.sphere_intersects_cell_bsp (returns Inside/Crossing/Outside) for the neighbour-containment test. Our BSPQuery doesn't currently expose that operation — only PointInsideCellBsp and the collision-side FindCollisions. The plane-side heuristic above is a conservative approximation: it adds a candidate whenever the sphere is close to the portal and on the right side. Combined with FindCellList's subsequent point-in-cell test (Task 6) which filters to a single winning cell via PointInsideCellBsp, the conservative add is safe.
A more retail-faithful implementation would add BSPQuery.SphereIntersectsCellBsp (~80 LOC). Deferred to a follow-up if our heuristic produces visible bugs.
- Step 4: Run tests, expect green
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindTransitCellsSphere"
Expected: 3 tests passing.
- Step 5: No commit yet — bundle with Task 5 + 6 + 7 into one CellTransit commit.
Task 5: Port CellTransit.AddAllOutsideCells (TDD)
Files:
-
Modify:
src/AcDream.Core/Physics/CellTransit.cs -
Create:
tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs -
Step 1: Write the failing tests
Create tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs:
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitAddAllOutsideCellsTests
{
// Outdoor landcells are 24×24m. Cell ids low-16 are 0x01..0x40 in row-major
// order (8 cells wide, 8 cells tall per landblock). Cell offset within
// landblock: cellY = (low - 1) % 8, cellX = (low - 1) / 8 (per the AC2D
// reference; verify with research doc).
[Fact]
public void SphereWellInsideCell_AddsOneCell()
{
// Player at world (12, 12, 0) — middle of cell (0, 0) of landblock 0xA9B40000.
// Cell low-16 id = 0x0001 (first outdoor cell).
var candidates = new HashSet<uint>();
CellTransit.AddAllOutsideCells(
worldSphereCenter: new Vector3(12f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u,
candidates);
Assert.Single(candidates);
Assert.Contains(0xA9B40001u, candidates);
}
[Fact]
public void SphereAtCellEastBoundary_AddsTwoCells()
{
// Player at world (23.6, 12, 0) — at the +X edge of cell (0,0). Sphere
// radius 0.5 → reaches X=24.1 which is in cell (1,0).
var candidates = new HashSet<uint>();
CellTransit.AddAllOutsideCells(
worldSphereCenter: new Vector3(23.6f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u,
candidates);
Assert.Equal(2, candidates.Count);
Assert.Contains(0xA9B40001u, candidates);
// Cell at (1, 0): low-16 id = 0x0009 (cell index = 8 → low+8 = 0x0009).
Assert.Contains(0xA9B40009u, candidates);
}
// Additional boundary tests (corner: 4 cells; +Y edge: 2 cells) intentionally
// omitted from the plan to keep the commit small. Add them once the basic
// shape works.
}
- Step 2: Add the function to
CellTransit
Append to src/AcDream.Core/Physics/CellTransit.cs:
/// <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;
// Compute the landblock's world XY origin from the landblock id.
// Landblock byte X = (lbPrefix >> 24) & 0xFF, Y = (lbPrefix >> 16) & 0xFF.
// Each landblock is 192×192m at world (lbX * 192, lbY * 192).
// The "current landblock" depends on world position — if the sphere
// crosses landblock boundaries we'd need to adjust the prefix; for
// now assume the sphere stays within the current landblock.
float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f;
float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f;
float localX = worldSphereCenter.X - lbXf;
float localY = worldSphereCenter.Y - lbYf;
// Within-cell local 2D.
float cellLocalX = localX % CellSize;
float cellLocalY = localY % CellSize;
float minRad = sphereRadius;
float maxRad = CellSize - sphereRadius;
// Grid coordinates.
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);
// Boundary checks (matches research doc's check_add_cell_boundary).
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, per AC's convention.
uint low = (uint)(gridX * 8 + gridY + 1);
candidates.Add(lbPrefix | low);
}
- Step 3: Run tests, expect green
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitAddAllOutsideCells"
Expected: 2 tests passing.
- Step 4: No commit yet.
Task 6: Port CellTransit.FindCellList (top-level driver, TDD)
Files:
-
Modify:
src/AcDream.Core/Physics/CellTransit.cs -
Create:
tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs -
Step 1: Write the failing tests
Create tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs:
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitFindCellListTests
{
[Fact]
public void IndoorSeed_PointInsideCellBsp_ReturnsCurrentCell()
{
var cache = new PhysicsDataCache();
// Synthetic cell with no portals; CellBSP is null so PointInsideCellBsp
// would return true — but we guard CellBSP?.Root == null and treat
// missing BSP as "not findable" → fall through.
var cell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
cache.RegisterCellStructForTest(0xA9B40100u, cell);
uint result = CellTransit.FindCellList(
cache,
worldSphereCenter: Vector3.Zero,
sphereRadius: 0.5f,
currentCellId: 0xA9B40100u);
// No CellBSP → falls back to the input cell id.
Assert.Equal(0xA9B40100u, result);
}
[Fact]
public void OutdoorSeed_Returns_OutdoorLandcell()
{
var cache = new PhysicsDataCache();
// Outdoor seed: low-16 < 0x0100. No CellPhysics needed for landcells.
uint result = CellTransit.FindCellList(
cache,
worldSphereCenter: new Vector3(12f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u);
Assert.Equal(0xA9B40001u, result);
}
}
- Step 2: Add
FindCellListtoCellTransit
Append to src/AcDream.Core/Physics/CellTransit.cs:
/// <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.
/// </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)
{
var 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>();
pending.Enqueue(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);
// For each NEW candidate, enqueue it.
if (candidates.Count > sizeBefore)
{
// Snapshot the new candidates to avoid mutating during iteration.
foreach (var c in candidates)
{
if (c != cellId) // skip seed
pending.Enqueue(c);
}
}
if (exitOutside)
{
// Add neighbour outdoor cells too.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
}
}
}
else
{
// Outdoor seed.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
// TODO: check_building_transit hookup at Task 7.
}
// 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;
}
- Step 3: Run tests, expect green
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindCellList"
Expected: 2 tests passing.
- Step 4: No commit yet.
Task 7: Wire CellTransit.FindCellList into ResolveCellId, delete AABB containment
Files:
-
Modify:
src/AcDream.Core/Physics/PhysicsEngine.cs -
Modify:
src/AcDream.Core/Physics/PhysicsDataCache.cs -
Modify:
src/AcDream.Core/Physics/TransitionTypes.cs -
Rename + rewrite:
tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs→tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs -
Step 1: Rename
ResolveOutdoorCellIdand rewrite body
In src/AcDream.Core/Physics/PhysicsEngine.cs, find ResolveOutdoorCellId at line 254. Rename to ResolveCellId and replace the body. New definition:
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
/// given world position via retail's portal-graph traversal. Delegates
/// to <see cref="CellTransit.FindCellList"/>.
///
/// <para>
/// Replaces Phase D's <c>ResolveOutdoorCellId</c> which used AABB
/// containment — see <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
/// for the design.
/// </para>
/// </summary>
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
if (fallbackCellId == 0) return 0;
if (DataCache is null) return fallbackCellId;
return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
}
- Step 2: Update the two internal callers in
PhysicsEngine.cs
Find lines 755 and 773 (or thereabouts after the rename). The current calls are:
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
Plumb the sphere radius through. The caller is inside ResolveWithTransition, and sp.GlobalSphere[0].Radius is accessible via the sp (SpherePath) variable. Update both lines:
ResolveCellId(sp.CheckPos, sp.GlobalSphere[0].Radius, sp.CheckCellId),
- Step 3: Update the
TransitionTypes.cscall
At src/AcDream.Core/Physics/TransitionTypes.cs:1181, the existing call:
uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
Replace with:
uint resolvedOutdoorCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId);
The sphereRadius local is already in scope at line 1186 (float sphereRadius = sp.GlobalSphere[0].Radius;). The replacement uses it directly.
- Step 4: Delete
TryFindContainingCellfromPhysicsDataCache
In src/AcDream.Core/Physics/PhysicsDataCache.cs, find the TryFindContainingCell method (around line 295). Delete the entire method including its XML doc.
- Step 5: Rebuild — should be green now
Run: dotnet build
If errors remain referencing LocalAabbMin / LocalAabbMax / TryFindContainingCell outside the deleted code, fix them. Most likely candidates: the WorldPicker tests if they constructed CellPhysics with those fields, or any other consumer that snuck in.
- Step 6: Rename + rewrite the Phase D test file
Rename tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs to tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs (file rename — use git mv).
Replace the class body. The old AABB-based tests assert containment behavior that no longer exists. New tests verify the portal-traversal-based behavior:
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class ResolveCellIdTests
{
[Fact]
public void ResolveCellId_FallbackZero_ReturnsZero()
{
var engine = new PhysicsEngine();
uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0u);
Assert.Equal(0u, result);
}
[Fact]
public void ResolveCellId_NoDataCache_ReturnsFallback()
{
// Build a PhysicsEngine without setting DataCache (default: null).
var engine = new PhysicsEngine { DataCache = null };
uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0x00000001u);
Assert.Equal(0x00000001u, result);
}
[Fact]
public void ResolveCellId_OutdoorSeedNoLandblock_ReturnsFallback()
{
var engine = new PhysicsEngine();
uint result = engine.ResolveCellId(
new Vector3(100, 100, 0),
sphereRadius: 0.5f,
fallbackCellId: 0xA9B40001u);
// No cells cached, no landblock added → AddAllOutsideCells produces 1
// candidate (the input cell) but PointInsideCellBsp on null CellBSP skips
// → returns fallback.
Assert.Equal(0xA9B40001u, result);
}
}
(The original Phase D tests covered specific behaviors that are now better covered by CellTransitFindCellListTests and CellTransitFindTransitCellsSphereTests. Don't try to port them 1:1.)
- Step 7: Build + full test sweep
Run: dotnet build && dotnet test
Expected:
-
Build green (0 errors).
-
All new tests pass.
-
8 pre-existing failures unchanged (baseline match).
-
Old
ResolveOutdoorCellIdTestsis gone (file renamed); the rewrittenResolveCellIdTests(3 tests) passes. -
Step 8: Commit (the cellTransit + integration commit)
git add src/AcDream.Core/Physics/CellTransit.cs `
src/AcDream.Core/Physics/PhysicsEngine.cs `
src/AcDream.Core/Physics/PhysicsDataCache.cs `
src/AcDream.Core/Physics/TransitionTypes.cs `
tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs `
tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs `
tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs `
tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs
git rm tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs
git commit -m "$(cat <<'EOF'
feat(physics): Phase 2 — port CellTransit + delete AABB containment
New CellTransit static class ports retail's portal-graph cell traversal:
- FindTransitCellsSphere — indoor portal-neighbour walk
- AddAllOutsideCells — outdoor 24m grid expansion
- FindCellList — top-level driver (BFS through portals;
PointInsideCellBsp for final containment)
PhysicsEngine.ResolveOutdoorCellId renamed to ResolveCellId. Body
rewritten to delegate to CellTransit.FindCellList. Signature extended
with sphereRadius parameter (needed by the sphere-vs-portal-plane test).
Three call sites updated (PhysicsEngine ×2, TransitionTypes ×1).
Deletes PhysicsDataCache.TryFindContainingCell + the corresponding AABB
compute. The previous Phase D AABB-containment tests are dropped; new
tests under CellTransit*Tests cover the equivalent scenarios via portal
traversal.
Outdoor→indoor entry (check_building_transit) is wired but no-op until
the BuildingPhysics infrastructure lands in a follow-up commit.
Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 8: Add BuildingPhysics + CheckBuildingTransit (TDD)
Files:
-
Create:
src/AcDream.Core/Physics/BuildingPhysics.cs -
Modify:
src/AcDream.Core/Physics/PhysicsDataCache.cs -
Modify:
src/AcDream.Core/Physics/CellTransit.cs -
Modify:
src/AcDream.App/Rendering/GameWindow.cs -
Create:
tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs -
Step 1: Create the
BuildingPhysicstype
Create src/AcDream.Core/Physics/BuildingPhysics.cs:
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
/// for outdoor→indoor cell entry. One per outdoor landcell that contains
/// a building stab. Mirrors retail's <c>BuildingObj.Portals</c> array
/// (per the pseudocode doc §"LandCell.find_transit_cells").
/// </summary>
public sealed class BuildingPhysics
{
public required Matrix4x4 WorldTransform { get; init; }
public required Matrix4x4 InverseWorldTransform { get; init; }
public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
}
/// <summary>
/// One building portal: the connection from a SortCell's BuildingObj to
/// an interior EnvCell.
/// </summary>
public readonly struct BldPortalInfo
{
public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags, bool exactMatch)
{
OtherCellId = otherCellId;
OtherPortalId = otherPortalId;
Flags = flags;
ExactMatch = exactMatch;
}
/// <summary>Full id of the interior EnvCell this portal connects to.</summary>
public uint OtherCellId { get; }
/// <summary>The portal id within the destination EnvCell.</summary>
public ushort OtherPortalId { get; }
public ushort Flags { get; }
public bool ExactMatch { get; }
}
- Step 2: Add
CacheBuilding+GetBuildingtoPhysicsDataCache
In src/AcDream.Core/Physics/PhysicsDataCache.cs, near the existing CacheCellStruct method (around line 180), add:
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
/// for an outdoor landcell that contains a building stab. Used by
/// <see cref="CellTransit.CheckBuildingTransit"/>.
/// </summary>
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
{
if (_buildings.ContainsKey(landcellId)) return;
Matrix4x4.Invert(worldTransform, out var inverse);
_buildings[landcellId] = new BuildingPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inverse,
Portals = portals,
};
}
public BuildingPhysics? GetBuilding(uint landcellId)
=> _buildings.TryGetValue(landcellId, out var b) ? b : null;
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
- Step 3: Write the failing test
Create tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs:
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests
{
[Fact]
public void SphereOverlapsBuildingPortal_AddsInteriorCell()
{
// Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
new BldPortalInfo(
otherCellId: 0xA9B40100u,
otherPortalId: 0,
flags: 0,
exactMatch: false),
},
};
// Interior cell whose CellBSP returns "inside" for the sphere center.
// For this test we use a null CellBSP — PointInsideCellBsp on null
// returns true, so the cell registers as containing the point.
var interiorCell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterBuildingForTest(0xA9B40001u, building);
cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
var candidates = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
cache,
building,
worldSphereCenter: new Vector3(0, 0, 0),
sphereRadius: 0.5f,
candidates);
Assert.Contains(0xA9B40100u, candidates);
}
}
- Step 4: Add
CheckBuildingTransittoCellTransit
Append to src/AcDream.Core/Physics/CellTransit.cs:
/// <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.PointInsideCellBsp"/>.
/// If so, add the interior cell to <paramref name="candidates"/>.
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
Vector3 worldSphereCenter,
float sphereRadius,
HashSet<uint> candidates)
{
foreach (var portal in building.Portals)
{
if (portal.OtherCellId == 0xFFFFFFFFu) continue;
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell is null) continue;
// Sphere center in the OTHER cell's local space.
var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
// Use PointInsideCellBsp if available; else fall through.
if (otherCell.CellBSP?.Root is null) continue;
if (BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter))
{
candidates.Add(portal.OtherCellId);
}
}
}
- Step 5: Wire
CheckBuildingTransitintoFindCellList
In src/AcDream.Core/Physics/CellTransit.cs, find the outdoor seed branch of FindCellList:
else
{
// Outdoor seed.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
// TODO: check_building_transit hookup at Task 7.
}
Replace the TODO with the building loop:
else
{
// Outdoor seed.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
// For each landcell candidate, check if it has a building stab.
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);
}
}
- Step 6: Wire
CacheBuildingat landblock load
In src/AcDream.App/Rendering/GameWindow.cs, find the existing lbInfo.Buildings iteration (around line 5641 where portal planes are extracted). Add a call to _physicsDataCache.CacheBuilding inside the loop, using the actual property names from Task 0. Example shape (the exact property names need substitution):
foreach (var building in lbInfo.Buildings)
{
// building.Portals → IReadOnlyList<BldPortal>
// building.Frame → world transform (or building.Transform — check Task 0)
var portals = new System.Collections.Generic.List<AcDream.Core.Physics.BldPortalInfo>(building.Portals.Count);
foreach (var bp in building.Portals)
{
portals.Add(new AcDream.Core.Physics.BldPortalInfo(
otherCellId: bp.OtherCellId,
otherPortalId: bp.OtherPortalId,
flags: (ushort)bp.Flags,
exactMatch: bp.ExactMatch));
}
// Compute the building's world transform.
var buildingTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
* System.Numerics.Matrix4x4.CreateTranslation(building.Frame.Origin + origin);
// Building lives in a specific outdoor landcell. The dat encoding is
// typically a "SortCell" id — derive it from building.Frame.Origin or
// the building's containing-cell field (verify in Task 0).
uint landcellId = (lb.LandblockId & 0xFFFF0000u)
| (uint)(((int)(building.Frame.Origin.X / 24f) * 8 + (int)(building.Frame.Origin.Y / 24f)) + 1);
_physicsDataCache.CacheBuilding(landcellId, portals, buildingTransform);
}
(The landcell-id derivation may need adjusting based on retail's exact SortCell rules — confirm during Task 0 investigation. If the building's containing-cell is stored explicitly in the DAT, use that instead of the X/Y math.)
- Step 7: Run all tests
Run: dotnet build && dotnet test
Expected: build green, all new tests pass, pre-existing 8 failures unchanged.
- Step 8: Commit
git add src/AcDream.Core/Physics/BuildingPhysics.cs `
src/AcDream.Core/Physics/PhysicsDataCache.cs `
src/AcDream.Core/Physics/CellTransit.cs `
src/AcDream.App/Rendering/GameWindow.cs `
tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
git commit -m "$(cat <<'EOF'
feat(physics): Phase 2 — port BuildingPhysics + CheckBuildingTransit
Adds outdoor→indoor cell entry via building portals. Ported from
retail's BuildingObj::find_building_transit_cells +
CEnvCell::check_building_transit.
New BuildingPhysics type holds the per-SortCell BldPortal list +
building world transform. CacheBuilding wires from GameWindow at
landblock load. CellTransit.FindCellList's outdoor branch now expands
each landcell candidate's building portals (if any) via
CheckBuildingTransit, which point-in-cell tests the destination
interior cell. Closes the "walk into a building from outside" path.
Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: Capture session (user-driven)
Goal: verify the four acceptance criteria from the spec live in the Holtburg cottage.
- Step 1: Build green
Run: dotnet build
Expected: 0 errors.
- Step 2: Launch the client
User runs:
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
$env:ACDREAM_DEVTOOLS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 |
Tee-Object -FilePath "launch-phase2-verify.log"
- Step 3: Test the four acceptance scenarios
- Indoor walking — Enter the Holtburg cottage. Walk around freely. Walls must block from inside. Furniture must still collide.
- Outdoor→indoor — Walk toward the cottage door from outside. The door must let you through; the walls beside the door must block.
- Indoor→outdoor — Walk back out through the door. Outdoor terrain collision must resume; you're no longer trapped inside.
- Indoor→indoor — If the cottage has multiple rooms, walk between them.
Close the window when done.
- Step 4: Verify the log shows clean transitions
Run:
rg -c "^\[cell-transit\]" launch-phase2-verify.log
rg -c "^\[indoor-bsp\].*result=Collided" launch-phase2-verify.log
Expected:
- Multiple
[cell-transit]lines including indoor↔outdoor crossings with proper landblock prefixes. [indoor-bsp] result=Collidedlines firing when the player walks into walls.
If neither shows up: portal traversal isn't firing. Re-launch with debugging or escalate.
- Step 5: Save log artifact
The log at launch-phase2-verify.log will be cited in the Phase F handoff doc. No commit needed yet.
Task 10: Docs cleanup + handoff
Files:
-
Modify:
docs/ISSUES.md -
Modify:
docs/plans/2026-04-11-roadmap.md -
Modify:
CLAUDE.md -
Create:
docs/research/<ship-date>-portal-cell-tracking-shipped-handoff.md -
Step 1: Close issues
In docs/ISSUES.md:
-
Move #87 (Indoor cell tracking uses AABB containment...) to "Recently closed". Status DONE. Closed date + commit SHAs.
-
Update #84 (blocked by air indoors): the remaining wall-pass-through symptom is closed by Phase 2. Move to "Recently closed".
-
Update #85 (pass through walls outside→in): the outdoor→indoor entry via
BuildingObjportals closes this. Move to "Recently closed". -
Step 2: Update roadmap
In docs/plans/2026-04-11-roadmap.md, add a "Recently shipped" row for "Indoor portal-based cell tracking" with date + commit SHAs. Remove any forward entry for this work.
- Step 3: Update CLAUDE.md
Update the "Currently in Phase..." paragraph to reflect Phase 2 shipped. Next phase is Claude's choice per work-order autonomy.
- Step 4: Write the shipped-handoff doc
Create docs/research/<ship-date>-portal-cell-tracking-shipped-handoff.md. Mirror the format of docs/research/2026-05-19-cluster-a-shipped-handoff.md. Cover:
-
Commits list with SHAs
-
One-paragraph summary of what shipped
-
Per-issue resolution (#87, #84, #85 all closed)
-
Probe evidence from
launch-phase2-verify.log -
Diagnostic infrastructure that persists (
[cell-cache],[indoor-bsp]) -
Follow-up items:
BSPQuery.SphereIntersectsCellBspfor retail-faithful neighbour-add; parts/AABB variant offind_transit_cellsfor remote entities;VisibleCellscleanup filter. -
Step 5: Final build + test sweep
Run: dotnet build && dotnet test
Expected: 0 errors, all new tests pass, 8 pre-existing failures unchanged.
- Step 6: Commit the docs
git add docs/ISSUES.md `
docs/plans/2026-04-11-roadmap.md `
CLAUDE.md `
docs/research/<ship-date>-portal-cell-tracking-shipped-handoff.md
git commit -m "$(cat <<'EOF'
docs(phase): Indoor portal-based cell tracking shipped
Closes ISSUES.md #87, #84, #85. Portal-graph cell traversal replaces
Phase D's AABB containment; player can now walk freely inside
buildings, walls block consistently, doors update CellId correctly,
walking into a building from outside works.
ISSUES.md: #84/#85/#87 → Recently closed.
Roadmap: Indoor portal cell tracking added to shipped table.
CLAUDE.md: current-phase paragraph updated.
Follow-up items (filed inline in handoff doc):
- BSPQuery.SphereIntersectsCellBsp port for retail-faithful neighbour-add
(currently uses a plane-side heuristic)
- parts/AABB variant of find_transit_cells for remote-entity cell tracking
- VisibleCells cleanup filter at end of find_cell_list
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Self-review checklist (run after writing the plan)
- Spec coverage: every spec section maps to a task. ✓ §1-2 → background; §3-4 architecture/components → Tasks 1-7; §5 data flow → Tasks 3 + 7; §6 commit shape → Tasks 3 + 7 + 8 + 10; §7 files → File Structure table; §9 testing → per-task unit tests + Task 9 live; §10 acceptance → Task 9.
- Placeholder scan: Two intentional
<CellBSP-property-name>substitutions remain in Task 3 — these get resolved at Task 0. The landcell-id derivation in Task 8 Step 6 may need tweaking based on Task 0 findings — flagged inline. No "TBD", "TODO", or unspecified behavior. - Type consistency:
PortalInfo(ushort, ushort, ushort)is consistent across Tasks 1, 2, 3, 4.BldPortalInfo(uint, ushort, ushort, bool)consistent across Tasks 8.CellTransit.FindCellList(cache, worldSphereCenter, sphereRadius, currentCellId) → uintconsistent across Tasks 6, 7.CellTransit.FindTransitCellsSphere(cache, currentCell, currentCellId, ws, r, candidates, out exitOutside)consistent across Tasks 4, 6.CellTransit.AddAllOutsideCells(ws, r, currentCellId, candidates)consistent across Tasks 5, 6, 7.CellTransit.CheckBuildingTransit(cache, building, ws, r, candidates)consistent across Task 8.
- Acceptance: matches spec §10. Visual verification by user in Task 9.