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>
1846 lines
74 KiB
Markdown
1846 lines
74 KiB
Markdown
# 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`](../specs/2026-05-19-indoor-portal-cell-tracking-design.md)
|
||
|
||
**Research:** [`docs/research/acclient_indoor_transitions_pseudocode.md`](../../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](../../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` (inside `ResolveWithTransition`)
|
||
- `PhysicsEngine.cs:773` (inside `ResolveWithTransition` fallback path)
|
||
- `TransitionTypes.cs:1181` (inside `Transition.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.CellBSP` exists**
|
||
|
||
Run from the worktree root:
|
||
```powershell
|
||
$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:
|
||
```bash
|
||
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):
|
||
```csharp
|
||
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.Buildings` shape**
|
||
|
||
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 `Portals` or `BldPortals`)
|
||
- The building's world transform (probably `Transform` or `Frame`)
|
||
|
||
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 BSP
|
||
- `lbInfo.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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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` (the `CellPhysics` class around line 422)
|
||
- Create: `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`
|
||
|
||
- [ ] **Step 1: Locate `CellPhysics` and confirm shape**
|
||
|
||
Open `src/AcDream.Core/Physics/PhysicsDataCache.cs` and locate the `CellPhysics` class around line 422. Confirm:
|
||
- It's a `sealed class` with `required` + `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:
|
||
1. ADD: `CellBSP` (`PhysicsBSPTree?`, init-only, nullable).
|
||
2. ADD: `Portals` (`IReadOnlyList<PortalInfo>`, init-only, default empty).
|
||
3. ADD: `PortalPolygons` (`Dictionary<ushort, ResolvedPolygon>?`, init-only, default null).
|
||
4. ADD: `VisibleCellIds` (`IReadOnlySet<uint>`, init-only, default empty).
|
||
5. DELETE: `LocalAabbMin` and `LocalAabbMax` (and their XML docs).
|
||
|
||
Concrete edit shape (the existing class continues to start with `public sealed class CellPhysics { ... }`):
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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` (the `CacheCellStruct` method 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 `ResolvePolygons` visibility**
|
||
|
||
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 `CacheCellStruct` signature**
|
||
|
||
Find the existing `CacheCellStruct` method at PhysicsDataCache.cs:131. Change the signature from:
|
||
|
||
```csharp
|
||
public void CacheCellStruct(uint envCellId, CellStruct cellStruct, Matrix4x4 worldTransform)
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```csharp
|
||
_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`:
|
||
|
||
```csharp
|
||
[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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
/// <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`:
|
||
|
||
```csharp
|
||
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 `FindCellList` to `CellTransit`**
|
||
|
||
Append to `src/AcDream.Core/Physics/CellTransit.cs`:
|
||
|
||
```csharp
|
||
/// <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 `ResolveOutdoorCellId` and rewrite body**
|
||
|
||
In `src/AcDream.Core/Physics/PhysicsEngine.cs`, find `ResolveOutdoorCellId` at line 254. Rename to `ResolveCellId` and replace the body. New definition:
|
||
|
||
```csharp
|
||
/// <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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
ResolveCellId(sp.CheckPos, sp.GlobalSphere[0].Radius, sp.CheckCellId),
|
||
```
|
||
|
||
- [ ] **Step 3: Update the `TransitionTypes.cs` call**
|
||
|
||
At `src/AcDream.Core/Physics/TransitionTypes.cs:1181`, the existing call:
|
||
|
||
```csharp
|
||
uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```csharp
|
||
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 `TryFindContainingCell` from `PhysicsDataCache`**
|
||
|
||
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:
|
||
|
||
```csharp
|
||
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 `ResolveOutdoorCellIdTests` is gone (file renamed); the rewritten `ResolveCellIdTests` (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 `BuildingPhysics` type**
|
||
|
||
Create `src/AcDream.Core/Physics/BuildingPhysics.cs`:
|
||
|
||
```csharp
|
||
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` + `GetBuilding` to `PhysicsDataCache`**
|
||
|
||
In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, near the existing `CacheCellStruct` method (around line 180), add:
|
||
|
||
```csharp
|
||
// ── 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`:
|
||
|
||
```csharp
|
||
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 `CheckBuildingTransit` to `CellTransit`**
|
||
|
||
Append to `src/AcDream.Core/Physics/CellTransit.cs`:
|
||
|
||
```csharp
|
||
/// <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 `CheckBuildingTransit` into `FindCellList`**
|
||
|
||
In `src/AcDream.Core/Physics/CellTransit.cs`, find the outdoor seed branch of `FindCellList`:
|
||
|
||
```csharp
|
||
else
|
||
{
|
||
// Outdoor seed.
|
||
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
|
||
// TODO: check_building_transit hookup at Task 7.
|
||
}
|
||
```
|
||
|
||
Replace the TODO with the building loop:
|
||
|
||
```csharp
|
||
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 `CacheBuilding` at 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):
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```powershell
|
||
$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**
|
||
|
||
1. **Indoor walking** — Enter the Holtburg cottage. Walk around freely. Walls must block from inside. Furniture must still collide.
|
||
2. **Outdoor→indoor** — Walk toward the cottage door from outside. The door must let you through; the walls beside the door must block.
|
||
3. **Indoor→outdoor** — Walk back out through the door. Outdoor terrain collision must resume; you're no longer trapped inside.
|
||
4. **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:
|
||
```bash
|
||
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=Collided` lines 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 `BuildingObj` portals 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.SphereIntersectsCellBsp` for retail-faithful neighbour-add; parts/AABB variant of `find_transit_cells` for remote entities; `VisibleCells` cleanup 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)
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **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) → uint` consistent 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.
|
||
4. **Acceptance:** matches spec §10. Visual verification by user in Task 9.
|