14-task plan covering the diagnostic-driven phase: probe + capture + three fix commits + docs. Tasks 1-6 land the [indoor-bsp] probe in one feature commit. Task 7 is the user-run capture gate. Tasks 8-11 do post-capture diagnosis + fix for #84 and #85 (with a route-δ escape hatch if #85's fix turns out to be a large cross-cell port). Tasks 12-13 ship the WorldPicker cell-BSP occlusion fix for #86 (no capture dependency — pinned by code-reading). Task 14 closes out ISSUES.md + roadmap + ships the post-phase handoff doc. Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1297 lines
54 KiB
Markdown
1297 lines
54 KiB
Markdown
# Indoor Walking Phase 1 — BSP Cluster (#84 / #85 / #86) 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:** Surface the root causes for ISSUES.md #84 (blocked by air indoors), #85 (pass through walls outside→in), and #86 (click selection penetrates walls) via a single diagnostic-driven capture session, then ship one surgical fix commit per issue.
|
||
|
||
**Architecture:** Add an `[indoor-bsp]` probe to `TransitionTypes.FindEnvCollisions`' cell-BSP branch (the indoor-collision code path already exists at lines 1188-1241 but emits no diagnostics). Capture one Holtburg Inn walkaround session that exercises all three issues. Read the log, pin each root cause to a specific code site, ship a separate surgical commit per issue. #86 has no probe dependency — its cause is already pinned by code-reading (WorldPicker.Pick has no cell-BSP test) — so its fix is structural.
|
||
|
||
**Tech Stack:** C# / .NET 10, xUnit, Silk.NET, Möller-Trumbore ray-triangle intersection. Uses existing physics + selection types. Probe writes to `Console.WriteLine` per the established `[indoor-*]` / `[resolve]` / `[cell-transit]` convention.
|
||
|
||
**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`](../specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md)
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Action | Responsibility |
|
||
|---|---|---|
|
||
| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` | modify | Add `ProbeIndoorBspEnabled` static property. |
|
||
| `src/AcDream.Core/Physics/BSPQuery.cs` | modify | Have all 8 `LastBspHitPoly = hitPoly` write sites fire when `ProbeIndoorBspEnabled` is true (currently only fires for `ProbeBuildingEnabled`). |
|
||
| `src/AcDream.Core/Physics/TransitionTypes.cs` | modify | Emit `[indoor-bsp]` log around the cell-BSP `FindCollisions` call at line 1222. |
|
||
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | modify | Add `ProbeIndoorBsp` runtime mirror property. |
|
||
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | modify | Add checkbox row beneath the existing indoor-render probes. |
|
||
| `src/AcDream.Core/Selection/CellBspRayOccluder.cs` | create | Pure Möller-Trumbore ray-triangle test against a set of `CellPhysics`. Returns nearest-wall `t` along ray. |
|
||
| `src/AcDream.Core/Selection/WorldPicker.cs` | modify | Both `Pick` overloads accept an optional `cellOccluder` callback. Production callers pass it; tests can pass `null`. |
|
||
| `src/AcDream.App/Rendering/GameWindow.cs` | modify | Wire `CellBspRayOccluder.NearestWallT` into the screen-rect `Pick` call at line 9134. |
|
||
| `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs` | create | Direct unit tests for Möller-Trumbore semantics. |
|
||
| `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs` | create | Integration test: synthetic wall poly between ray origin and entity → no hit. |
|
||
| `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs` | modify | Add a parity test for the new `ProbeIndoorBsp` mirror. |
|
||
| `docs/ISSUES.md` | modify | Move #84/#85/#86 to "Recently closed" with commit SHAs. |
|
||
| `docs/plans/2026-04-11-roadmap.md` | modify | Add shipped-table entry for the phase. |
|
||
|
||
---
|
||
|
||
## Probe pre-work: code-shape facts
|
||
|
||
These facts are referenced in many tasks below. Read them once.
|
||
|
||
**Fact 1.** `PhysicsDiagnostics.cs` already has six toggles (`ProbeResolveEnabled`, `ProbeCellEnabled`, `ProbeBuildingEnabled`, `ProbeAutoWalkEnabled`, `ProbeUseabilityFallbackEnabled`, `DumpSteepRoofEnabled`) plus the `LastBspHitPoly` diagnostic side-channel. Pattern is `public static bool Foo { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_FOO") == "1";`.
|
||
|
||
**Fact 2.** `BSPQuery.cs` has 8 sites that write `PhysicsDiagnostics.LastBspHitPoly = hitPoly;`, each gated by `if (PhysicsDiagnostics.ProbeBuildingEnabled)`. The sites are at lines 1219, 1232, 1239, 1555, 1589, 1673, 1683, 1713, 1722. (Verify line numbers before editing — file evolves.)
|
||
|
||
**Fact 3.** `TransitionTypes.FindEnvCollisions` cell-BSP branch lives at TransitionTypes.cs:1188-1241. The `BSPQuery.FindCollisions` call is at line 1222. Pre-call, the engine has `cellPhysics`, `localSphere`, `localCurrCenter`, `sp.CheckCellId`, `footCenter`. Post-call, `cellState` carries the outcome and `PhysicsDiagnostics.LastBspHitPoly` carries the hit poly (if our probe and the indoor flag fire together).
|
||
|
||
**Fact 4.** Only ONE production `WorldPicker.Pick` call exists, at GameWindow.cs:9134 — the screen-rect overload. The legacy ray-sphere overload at WorldPicker.cs:88-160 is test-only.
|
||
|
||
**Fact 5.** Cell physics caching site is `_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)` at GameWindow.cs:5384 — applied to ALL loaded EnvCells. Access via `_physicsDataCache.GetCellStruct(envCellId)` returning a `CellPhysics?` with `BSP`, `Resolved`, `WorldTransform`, `InverseWorldTransform`.
|
||
|
||
**Fact 6.** `CellPhysics.Resolved` is a `Dictionary<ushort, ResolvedPolygon>` — keys are poly ids, values include `Vertices` (already-resolved world-positions, but they're in LOCAL space — multiply by `WorldTransform` to get world), `Plane`, `NumPoints`, `SidesType`. (Confirm by reading `PhysicsDataCache.ResolvePolygons` lines 155-204.)
|
||
|
||
**Fact 7.** Three loaded sets the picker needs:
|
||
- `_physicsDataCache.GetCellStruct(id)` — looks up one cell's BSP.
|
||
- The set of currently-loaded EnvCell ids — enumerate via `_cellVisibility._cellLookup` (if that's accessible from GameWindow) or by iterating `_pendingCellMeshes.Keys` / a similar field. **Confirm during Task 10** which collection is the authoritative list of loaded EnvCells in GameWindow.
|
||
|
||
---
|
||
|
||
## Task 1: Add `PhysicsDiagnostics.ProbeIndoorBspEnabled`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`
|
||
|
||
- [ ] **Step 1: Add the new toggle property at the bottom of the existing toggle list**
|
||
|
||
In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, immediately before the closing `}` of the class (after `DumpSteepRoofEnabled` at line 168), add:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Indoor walking Phase 1 (2026-05-19). When true, emits one
|
||
/// <c>[indoor-bsp]</c> line per <see cref="BSPQuery.FindCollisions"/>
|
||
/// call made from <see cref="Transition.FindEnvCollisions"/>'s indoor
|
||
/// cell-BSP branch. Captures the cell id, sphere local position,
|
||
/// resulting <see cref="TransitionState"/>, and the hit poly's id,
|
||
/// local-normal, and side-type — pinpoints why indoor collision
|
||
/// returns spurious collisions (#84) and helps cross-check the
|
||
/// outdoor-in approach path (#85).
|
||
///
|
||
/// <para>
|
||
/// While true, this also un-gates the diagnostic
|
||
/// <see cref="LastBspHitPoly"/> side-channel inside
|
||
/// <see cref="BSPQuery"/> — see the OR'd condition at every poly
|
||
/// write site. Zero-cost when off.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
|
||
/// Runtime-toggleable via DebugPanel.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public static bool ProbeIndoorBspEnabled { get; set; } =
|
||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
|
||
Expected: Build succeeds, 0 errors.
|
||
|
||
- [ ] **Step 3: No commit yet** — bundle with Task 2 and Task 3 into one probe-feature commit.
|
||
|
||
---
|
||
|
||
## Task 2: OR `ProbeIndoorBspEnabled` into BSPQuery's `LastBspHitPoly` write sites
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/BSPQuery.cs`
|
||
|
||
**Why:** `BSPQuery` currently writes `PhysicsDiagnostics.LastBspHitPoly` only when `ProbeBuildingEnabled` is true. The indoor probe needs the same side-channel; un-gate it for either flag.
|
||
|
||
- [ ] **Step 1: Find every write site**
|
||
|
||
Run: `rg -n "PhysicsDiagnostics.LastBspHitPoly = hitPoly" src/AcDream.Core/Physics/BSPQuery.cs`
|
||
|
||
Expected: 8 lines, each immediately preceded by `if (PhysicsDiagnostics.ProbeBuildingEnabled)`.
|
||
|
||
- [ ] **Step 2: Replace the gate at each site**
|
||
|
||
For each of the 8 occurrences, edit the gate from:
|
||
```csharp
|
||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||
```
|
||
to:
|
||
```csharp
|
||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||
```
|
||
|
||
(The local variable name varies: `hitPoly`, `hitPoly0`, `hitPoly1`. Preserve the local name at each site.)
|
||
|
||
Use the Edit tool one site at a time. If two sites have identical surrounding text, use `replace_all: true` on the gate-only string `if (PhysicsDiagnostics.ProbeBuildingEnabled)` since the OR transformation is identical for every site that this exact gate immediately precedes a `LastBspHitPoly` write. But verify no other call uses the same gate before doing replace_all.
|
||
|
||
Confirm via:
|
||
`rg -n "PhysicsDiagnostics.ProbeBuildingEnabled\b" src/AcDream.Core/Physics/BSPQuery.cs`
|
||
Expected: every match is followed on the next line by a `LastBspHitPoly` write.
|
||
|
||
- [ ] **Step 3: Verify build**
|
||
|
||
Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
|
||
Expected: Build succeeds.
|
||
|
||
- [ ] **Step 4: Run BSPQuery tests**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BSPQuery"`
|
||
Expected: all pass; behavior unchanged when both flags are false.
|
||
|
||
- [ ] **Step 5: No commit yet** — bundle with Task 1 and Task 3.
|
||
|
||
---
|
||
|
||
## Task 3: Emit `[indoor-bsp]` log line in `FindEnvCollisions`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs`
|
||
|
||
**Why:** One log line per cell-BSP collision query, with enough fields to diagnose poly Z-bump misalignment (#84) and out-of-cell asymmetry (#85). Bracketed prefix matches the existing `[indoor-*]` convention.
|
||
|
||
- [ ] **Step 1: Locate the insertion site**
|
||
|
||
Verify the cell branch is still at TransitionTypes.cs:1188-1241 (line numbers may drift). Find the exact `var cellState = BSPQuery.FindCollisions(` line — currently 1222.
|
||
|
||
- [ ] **Step 2: Add `using System.Globalization;` if not already present**
|
||
|
||
Check the file header. If missing, add `using System.Globalization;` to the using block at the top of the file.
|
||
|
||
- [ ] **Step 3: Wrap the FindCollisions call with the probe**
|
||
|
||
Replace the existing block at lines ~1220-1239:
|
||
|
||
```csharp
|
||
// Use the full 6-path BSP dispatcher for retail-faithful collision.
|
||
// Use pre-resolved polygons (vertices+planes computed at cache time).
|
||
var cellState = BSPQuery.FindCollisions(
|
||
cellPhysics.BSP.Root,
|
||
cellPhysics.Resolved,
|
||
this,
|
||
localSphere,
|
||
localSphere1,
|
||
localCurrCenter,
|
||
Vector3.UnitZ, // local space Z is up
|
||
1.0f, // scale = 1.0 for cell geometry
|
||
Quaternion.Identity,
|
||
engine); // engine needed for Path 5 step-up
|
||
|
||
if (cellState != TransitionState.OK)
|
||
{
|
||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
||
ci.CollidedWithEnvironment = true;
|
||
return cellState;
|
||
}
|
||
```
|
||
|
||
with:
|
||
|
||
```csharp
|
||
// Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly
|
||
// side-channel before the call so a missed write (no collision)
|
||
// is greppable as "poly=n/a" in the probe line below.
|
||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||
PhysicsDiagnostics.LastBspHitPoly = null;
|
||
|
||
// Use the full 6-path BSP dispatcher for retail-faithful collision.
|
||
// Use pre-resolved polygons (vertices+planes computed at cache time).
|
||
var cellState = BSPQuery.FindCollisions(
|
||
cellPhysics.BSP.Root,
|
||
cellPhysics.Resolved,
|
||
this,
|
||
localSphere,
|
||
localSphere1,
|
||
localCurrCenter,
|
||
Vector3.UnitZ, // local space Z is up
|
||
1.0f, // scale = 1.0 for cell geometry
|
||
Quaternion.Identity,
|
||
engine); // engine needed for Path 5 step-up
|
||
|
||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||
{
|
||
var hit = PhysicsDiagnostics.LastBspHitPoly;
|
||
string polyDesc = hit is null
|
||
? "poly=n/a"
|
||
: System.FormattableString.Invariant(
|
||
$"poly=0x{0:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}");
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[indoor-bsp] cell=0x{sp.CheckCellId:X8} " +
|
||
$"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) " +
|
||
$"lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) " +
|
||
$"lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) " +
|
||
$"r={sphereRadius:F3} result={cellState} {polyDesc}"));
|
||
}
|
||
|
||
if (cellState != TransitionState.OK)
|
||
{
|
||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
||
ci.CollidedWithEnvironment = true;
|
||
return cellState;
|
||
}
|
||
```
|
||
|
||
**Note:** `LastBspHitPoly` is a `ResolvedPolygon?` (a struct or class — check). The format string assumes `hit.Plane.Normal` works. The id field is not stored on `ResolvedPolygon` directly (only the value lives in the dict). The probe substitutes `0x0000` for the id field — if poly-id is needed for triage, extend `ResolvedPolygon` to carry its key in a follow-up. Capture diagnoses don't usually need the id; the normal + side-type + local-z is enough.
|
||
|
||
- [ ] **Step 4: Verify build**
|
||
|
||
Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
|
||
Expected: Build succeeds.
|
||
|
||
- [ ] **Step 5: Run physics tests**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Transition"`
|
||
Expected: all pass — probe is zero-cost when off, and the toggle defaults off.
|
||
|
||
---
|
||
|
||
## Task 4: Add DebugVM mirror + DebugPanel checkbox
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
|
||
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`
|
||
|
||
- [ ] **Step 1: Add `ProbeIndoorBsp` property to DebugVM**
|
||
|
||
In `DebugVM.cs`, after the existing `ProbeIndoorCull` property (around line 346), add:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Indoor walking Phase 1 (2026-05-19). Runtime mirror of
|
||
/// <c>PhysicsDiagnostics.ProbeIndoorBspEnabled</c> (env var
|
||
/// <c>ACDREAM_PROBE_INDOOR_BSP</c>). Toggling here flips the
|
||
/// <c>[indoor-bsp]</c> probe live — no relaunch required.
|
||
/// Physics-side companion to the five render-side
|
||
/// <c>ProbeIndoor*</c> mirrors directly above.
|
||
/// </summary>
|
||
public bool ProbeIndoorBsp
|
||
{
|
||
get => PhysicsDiagnostics.ProbeIndoorBspEnabled;
|
||
set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add checkbox row to DebugPanel**
|
||
|
||
In `DebugPanel.cs`, locate the existing indoor-probe block (the six `if (r.Checkbox("Indoor: ...", ...))` lines around 271-276).
|
||
|
||
After the last existing checkbox (`Indoor: cull`), add:
|
||
|
||
```csharp
|
||
bool probeIndoorBsp = _vm.ProbeIndoorBsp;
|
||
if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp;
|
||
```
|
||
|
||
Also update the local-variable block above (around line 264-269) to include the new local. Insert under `bool probeIndoorCull = _vm.ProbeIndoorCull;`:
|
||
|
||
```csharp
|
||
// probeIndoorBsp added below (physics-side; not part of IndoorAll cascade)
|
||
```
|
||
|
||
(Placement comment for human readers — no functional impact.)
|
||
|
||
- [ ] **Step 3: Verify build**
|
||
|
||
Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj`
|
||
Expected: Build succeeds.
|
||
|
||
---
|
||
|
||
## Task 5: Add DebugVM parity test for `ProbeIndoorBsp`
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs`
|
||
|
||
- [ ] **Step 1: Read the existing parity test for `ProbeBuilding`**
|
||
|
||
Run: `rg -n "ProbeBuilding" tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs`
|
||
|
||
Find the test that verifies `vm.ProbeBuilding = true` flips `PhysicsDiagnostics.ProbeBuildingEnabled`. Use it as a template.
|
||
|
||
- [ ] **Step 2: Add the parity test**
|
||
|
||
Add a new test method after the existing `ProbeBuilding` test:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics()
|
||
{
|
||
var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled;
|
||
try
|
||
{
|
||
var vm = MakeVm(); // Use the existing test factory in this file.
|
||
|
||
vm.ProbeIndoorBsp = true;
|
||
Assert.True(PhysicsDiagnostics.ProbeIndoorBspEnabled);
|
||
Assert.True(vm.ProbeIndoorBsp);
|
||
|
||
vm.ProbeIndoorBsp = false;
|
||
Assert.False(PhysicsDiagnostics.ProbeIndoorBspEnabled);
|
||
Assert.False(vm.ProbeIndoorBsp);
|
||
}
|
||
finally
|
||
{
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = originalEnabled;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note:** Check whether the test class has a `MakeVm()` helper. If not, look at how `ProbeBuilding_ForwardsToPhysicsDiagnostics` (or similar) constructs the VM and mirror that pattern.
|
||
|
||
- [ ] **Step 3: Verify the test runs and passes**
|
||
|
||
Run: `dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj --filter "FullyQualifiedName~ProbeIndoorBsp"`
|
||
Expected: 1 test passing.
|
||
|
||
- [ ] **Step 4: Full test sweep**
|
||
|
||
Run: `dotnet build` then `dotnet test`
|
||
Expected: all tests green.
|
||
|
||
---
|
||
|
||
## Task 6: Commit the probe feature
|
||
|
||
- [ ] **Step 1: Stage files**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs \
|
||
src/AcDream.Core/Physics/BSPQuery.cs \
|
||
src/AcDream.Core/Physics/TransitionTypes.cs \
|
||
src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs \
|
||
src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs \
|
||
tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(physics): Cluster A — indoor BSP collision probe
|
||
|
||
Adds the [indoor-bsp] probe + ProbeIndoorBspEnabled toggle for the
|
||
Indoor walking Phase 1 BSP-cluster investigation. Mirrors the existing
|
||
[resolve] / [cell-transit] / [indoor-*] pattern: one log line per
|
||
BSPQuery.FindCollisions call from FindEnvCollisions' cell branch,
|
||
capturing cell id, sphere local-pos, result TransitionState, and the
|
||
hit poly's normal + side-type via the LastBspHitPoly side-channel
|
||
(already wired for ProbeBuildingEnabled, now also fires for the indoor
|
||
flag).
|
||
|
||
Toggle via ACDREAM_PROBE_INDOOR_BSP=1 env var or DebugPanel checkbox.
|
||
Zero-cost when off.
|
||
|
||
Predecessor for the three fix commits that will close ISSUES.md
|
||
#84/#85/#86 after the capture session.
|
||
|
||
Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md
|
||
Plan: docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
- [ ] **Step 3: Verify commit**
|
||
|
||
```bash
|
||
git log -1 --oneline
|
||
git status
|
||
```
|
||
Expected: One new commit on `claude/competent-robinson-dec1f4`; working tree clean.
|
||
|
||
---
|
||
|
||
## ━━━ CAPTURE GATE — runs once, between Task 6 and Task 7 ━━━
|
||
|
||
This gate requires the user to run the client. Do not attempt to fully automate it; the user is the test subject.
|
||
|
||
### Task 7: Capture session
|
||
|
||
**Goal:** Produce `launch.log` lines that pin the root cause of #84 and inform #85.
|
||
|
||
- [ ] **Step 1: Confirm `dotnet build` is green**
|
||
|
||
Run: `dotnet build`
|
||
Expected: 0 errors, 0 warnings.
|
||
|
||
- [ ] **Step 2: Hand the user the launch command**
|
||
|
||
Print the following block back to the user verbatim so they can paste it into PowerShell:
|
||
|
||
```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_RESOLVE = "1"
|
||
$env:ACDREAM_PROBE_CELL = "1"
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
|
||
Tee-Object -FilePath "launch-cluster-a-capture.log"
|
||
```
|
||
|
||
- [ ] **Step 3: Hand the user the capture script (three scenarios)**
|
||
|
||
Ask the user to perform, in order, while the client is in-world:
|
||
|
||
1. **Inside Inn walkaround.** Walk into Holtburg Inn through the front door. Once inside, walk slowly around the common room (front-to-back, then a circuit along the walls). Stop wherever an invisible block happens — note the on-screen position before retrying. ~30 seconds.
|
||
2. **Outside-in approach.** Exit the Inn. Stand 5–10 m from the Inn's west exterior wall in open ground. Sprint at the wall (`W` + Shift held). Observe whether you pass through. ~30 seconds.
|
||
3. **Inside-out sanity.** Re-enter the Inn through the door. Walk into one interior wall directly. Confirm the wall blocks (this is the working direction). ~15 seconds.
|
||
|
||
Total: ~80 seconds of walking. One launch.
|
||
|
||
- [ ] **Step 4: Wait for the user to confirm capture is done**
|
||
|
||
After the user closes the client, the log is at `launch-cluster-a-capture.log` in the worktree root.
|
||
|
||
---
|
||
|
||
## Task 8: Diagnose #84 from captured log
|
||
|
||
**Files:** Read-only.
|
||
|
||
- [ ] **Step 1: Confirm the log exists and has indoor-bsp lines**
|
||
|
||
Run: `rg -c "^\[indoor-bsp\]" launch-cluster-a-capture.log`
|
||
Expected: a positive number (the count of probe lines).
|
||
|
||
If 0: the probe didn't fire — either the user wasn't actually in an indoor cell, or the flag wasn't set. Re-check launch command and re-capture.
|
||
|
||
- [ ] **Step 2: Find the most-frequent "Collided" cell + poly**
|
||
|
||
Run:
|
||
```bash
|
||
rg "^\[indoor-bsp\]" launch-cluster-a-capture.log | rg "result=Collided" | head -200
|
||
```
|
||
|
||
Identify recurrent patterns. Look for:
|
||
- Same `cell=0x...` appearing repeatedly even when the player visually wasn't near a wall.
|
||
- `lpos=` Z component that's slightly negative (`-0.02`-ish) → +0.02f Z-bump hypothesis.
|
||
- Polys with `n=(0,0,1)` (floor up-normals) firing far from visible floor edges → bogus floor poly hypothesis.
|
||
- Polys with `sides=Back` or unusual side-types → one-sided handling hypothesis.
|
||
|
||
- [ ] **Step 3: Cross-ref with the user's reported invisible-block positions**
|
||
|
||
Use the world position from each `wpos=` field to identify which probe lines correspond to actual user-reported invisible blocks. The user reports them by approximate location; the log gives exact Z + cell. If user reports "near the back wall but a meter shy", filter `[indoor-bsp]` to that cell's lines and identify what poly fired.
|
||
|
||
- [ ] **Step 4: Identify ONE specific root cause**
|
||
|
||
Pin to one of:
|
||
- **(a) Z-bump asymmetry**: `lpos.Z` consistently slightly below 0 while `n=(0,0,1)` polys collide. Fix: remove the +0.02f from the physics path's `cellTransform` while keeping it for render, OR bump player Z by +0.02f when in an indoor cell.
|
||
- **(b) Bogus physics-only polys**: collisions fire at world positions where the user reports no visible wall, AND the contacted poly's normal points in a direction inconsistent with any visible geometry. Fix: filter polys by side-type at cache time OR ignore polys whose plane doesn't intersect the cell's visible volume.
|
||
- **(c) Step-up regression at cell boundary**: collisions fire as the player crosses a cell boundary (preceded by a `[cell-transit]` line). Fix: ensure the cell-BSP path handles the cell-id-change case correctly.
|
||
- **(d) Something the data shows that we didn't predict.** Write a one-paragraph note in the eventual commit message.
|
||
|
||
- [ ] **Step 5: Write a one-paragraph diagnosis to `docs/research/2026-05-19-cluster-a-diagnosis.md`**
|
||
|
||
This doc is the evidence file for the upcoming commits. Format:
|
||
|
||
```markdown
|
||
# Cluster A — captured diagnosis (2026-05-19)
|
||
|
||
**Capture:** `launch-cluster-a-capture.log`.
|
||
|
||
## #84 root cause
|
||
|
||
<one paragraph: which probe lines, what they showed, what code site is implicated>
|
||
|
||
Sample probe line:
|
||
```
|
||
[indoor-bsp] cell=0x... wpos=... lpos=... result=Collided poly=... n=... sides=...
|
||
```
|
||
|
||
## #85 root cause
|
||
|
||
(filled by Task 9)
|
||
```
|
||
|
||
- [ ] **Step 6: No commit yet — proceed to fix.**
|
||
|
||
---
|
||
|
||
## Task 9: Apply #84 fix
|
||
|
||
**Files:** TBD based on Task 8's diagnosis. Most likely candidates:
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` (the `+0.02f` Z-bump at line 5362).
|
||
- `src/AcDream.Core/Physics/TransitionTypes.cs` (the cell-BSP branch).
|
||
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the polygon resolve step).
|
||
- `src/AcDream.Core/Physics/BSPQuery.cs` (the BSP query dispatcher).
|
||
|
||
- [ ] **Step 1: Apply the surgical fix**
|
||
|
||
Per the diagnosis. Code samples for the most likely two cases:
|
||
|
||
**If (a) Z-bump asymmetry**: at GameWindow.cs:5360-5365, split the bumped transform into a render-only Z-bump while keeping physics aligned to terrain. Replace:
|
||
|
||
```csharp
|
||
var cellOrigin = envCell.Position.Origin + lbOffset
|
||
+ new System.Numerics.Vector3(0f, 0f, 0.02f);
|
||
var cellTransform =
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||
|
||
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
|
||
```
|
||
|
||
with:
|
||
|
||
```csharp
|
||
// Two cellOrigins: render is bumped +0.02 m on Z to
|
||
// prevent z-fight with terrain; physics stays aligned
|
||
// with terrain so the player's foot-Z (from terrain
|
||
// sample) matches the cell BSP's local floor.
|
||
// (Cluster A #84 — capture identified the bump as the
|
||
// source of "blocked by air" at cell boundaries.)
|
||
var cellOriginPhysics = envCell.Position.Origin + lbOffset;
|
||
var cellOriginRender = cellOriginPhysics
|
||
+ new System.Numerics.Vector3(0f, 0f, 0.02f);
|
||
|
||
var orientationMat =
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation);
|
||
|
||
var cellTransformRender =
|
||
orientationMat *
|
||
System.Numerics.Matrix4x4.CreateTranslation(cellOriginRender);
|
||
var cellTransformPhysics =
|
||
orientationMat *
|
||
System.Numerics.Matrix4x4.CreateTranslation(cellOriginPhysics);
|
||
|
||
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransformRender);
|
||
```
|
||
|
||
Then at line 5384, change the physics cache call to pass `cellTransformPhysics`:
|
||
|
||
```csharp
|
||
_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransformPhysics);
|
||
```
|
||
|
||
And at line 5381 (the `BuildLoadedCell` call), evaluate whether to pass render or physics (whichever the visibility code path uses). Inspect `BuildLoadedCell` and adjust if needed.
|
||
|
||
**If (b) bogus physics-only polys**: filter in `PhysicsDataCache.ResolvePolygons` at line 155-204 by skipping polys whose `SidesType` value indicates a back-face-only or physics-stub. Reference: check `DatReaderWriter.Types.SidesType` enum. Add a `continue` for any side-type identified in capture.
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
Run: `dotnet build`
|
||
Expected: Build succeeds.
|
||
|
||
- [ ] **Step 3: Run physics tests**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Physics"`
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 4: Re-launch the client + Inside-Inn walkaround**
|
||
|
||
Same launch command as Task 7 Step 2. User walks the same inside-Inn loop. Verify no invisible blocks. `rg "^\[indoor-bsp\]" launch.log | rg result=Collided | wc -l` should be ~0 except at actual walls.
|
||
|
||
- [ ] **Step 5: Commit the fix**
|
||
|
||
```bash
|
||
git add <files-touched>
|
||
git commit -m "$(cat <<'EOF'
|
||
fix(physics): Cluster A #84 — <one-line root cause>
|
||
|
||
<3-5 line description citing the probe-line evidence>
|
||
|
||
Closes #84.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
Replace `<one-line root cause>` and the body with the actual evidence from Task 8 / Step 5's diagnosis doc.
|
||
|
||
---
|
||
|
||
## Task 10: Diagnose #85 from captured log
|
||
|
||
**Files:** Read-only.
|
||
|
||
- [ ] **Step 1: Confirm scenario 2 lines are present**
|
||
|
||
Run:
|
||
```bash
|
||
rg "^\[resolve\]" launch-cluster-a-capture.log | head -100
|
||
```
|
||
|
||
Look for lines emitted during the outside-in approach. Time-correlate via line ordering — scenario 2 follows scenario 1.
|
||
|
||
- [ ] **Step 2: Identify what (if anything) was hit during outside-in approach**
|
||
|
||
If `[resolve]` lines during scenario 2 show `obj=0x...` for the building stab, the outdoor BSP IS being consulted — the question is why it doesn't block. Inspect that stab's polys via:
|
||
```bash
|
||
rg "0xA9B47900" launch-cluster-a-capture.log # adjust to the obj id observed
|
||
```
|
||
|
||
If no `[resolve]` lines have `obj=...` for the building during scenario 2, the outdoor BSP isn't being engaged at all — the question is why `FindObjCollisions` doesn't iterate the building's stab.
|
||
|
||
- [ ] **Step 3: Identify ONE specific root cause**
|
||
|
||
Pin to one of:
|
||
- **(α) Building stab BSP exists but polys are one-sided**. Approach from outside fails the BSP traversal direction test. Fix: change side-type handling in `BSPQuery.FindCollisions` for the outdoor-stab path OR mark stab polys two-sided at cache time.
|
||
- **(β) Building stab is in the loaded set but never iterated**. The `FindObjCollisions` loop skips it for some reason (cell mismatch, scale mismatch, etc.). Fix: ensure the building stab's shadow-entry registration covers the outdoor cells the player walks through.
|
||
- **(γ) Building stab has no wall polys**. Retail's building shells are partial — they cover floor/roof, with interior walls in the EnvCell. Fix: port retail's cross-cell BSP probing (when sphere overlaps an EnvCell's world AABB from an outdoor cell, query that EnvCell's BSP too).
|
||
- **(δ) Risk path: if (γ) is the root cause and the port is large**, promote #85 to its own phase. Pause this plan and write a new phase spec for the cross-cell BSP work, then return to this plan for #86.
|
||
|
||
- [ ] **Step 4: Add #85 diagnosis to `docs/research/2026-05-19-cluster-a-diagnosis.md`**
|
||
|
||
Mirror the format from Task 8 Step 5.
|
||
|
||
- [ ] **Step 5: If route (δ) triggers — split out #85**
|
||
|
||
Stop the plan here. Write a new spec `docs/superpowers/specs/2026-05-DD-cluster-a-cross-cell-bsp-design.md` for #85's cross-cell port; come back to this plan and skip to Task 12 (#86 fix) immediately.
|
||
|
||
---
|
||
|
||
## Task 11: Apply #85 fix
|
||
|
||
**Files:** TBD based on Task 10's diagnosis. Most likely candidates:
|
||
- `src/AcDream.Core/Physics/BSPQuery.cs` (side-type handling).
|
||
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` (stab caching).
|
||
- `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindObjCollisions` or cell-BSP cross-probe).
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` (cell-stab registration).
|
||
|
||
- [ ] **Step 1: Apply the surgical fix**
|
||
|
||
Per the Task 10 diagnosis. The actual code is determined by which root cause (α / β / γ) the capture pinned. The diagnosis doc records the evidence; the commit body cites it.
|
||
|
||
- [ ] **Step 2: Build + test**
|
||
|
||
Run: `dotnet build && dotnet test`
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 3: Re-launch + outside-in scenario**
|
||
|
||
User stands 5+ m west of the Inn, sprints at the wall. Verify: player blocks at the wall plane.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add <files-touched>
|
||
git commit -m "fix(physics): Cluster A #85 — <root cause>
|
||
|
||
<evidence-citing description>
|
||
|
||
Closes #85.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Create `CellBspRayOccluder` for #86
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/Selection/CellBspRayOccluder.cs`
|
||
- Create: `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs`
|
||
|
||
- [ ] **Step 1: Write the test file FIRST (TDD)**
|
||
|
||
Create `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs`:
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using AcDream.Core.Selection;
|
||
using DatReaderWriter.Types;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Selection;
|
||
|
||
public class CellBspRayOccluderTests
|
||
{
|
||
// Build a CellPhysics with a single triangular poly at world-Y=10.
|
||
// Triangle vertices in local space, then world transform = identity.
|
||
private static CellPhysics MakeWallCell()
|
||
{
|
||
var verts = new[]
|
||
{
|
||
new Vector3(-5, 10, 0),
|
||
new Vector3( 5, 10, 0),
|
||
new Vector3( 0, 10, 5),
|
||
};
|
||
var poly = new ResolvedPolygon
|
||
{
|
||
Vertices = verts,
|
||
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||
NumPoints = 3,
|
||
SidesType = SidesType.Front,
|
||
};
|
||
return new CellPhysics
|
||
{
|
||
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
|
||
Resolved = new() { [0] = poly },
|
||
WorldTransform = Matrix4x4.Identity,
|
||
InverseWorldTransform = Matrix4x4.Identity,
|
||
};
|
||
}
|
||
|
||
[Fact]
|
||
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
|
||
{
|
||
var cell = MakeWallCell();
|
||
var origin = new Vector3(0, 0, 1);
|
||
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
|
||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
|
||
}
|
||
|
||
[Fact]
|
||
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
|
||
{
|
||
var cell = MakeWallCell();
|
||
var origin = new Vector3(0, 0, 1);
|
||
var direction = -Vector3.UnitY; // travels AWAY from the wall
|
||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
|
||
}
|
||
|
||
[Fact]
|
||
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
|
||
{
|
||
var origin = Vector3.Zero;
|
||
var direction = Vector3.UnitY;
|
||
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
|
||
Assert.True(float.IsPositiveInfinity(t));
|
||
}
|
||
|
||
[Fact]
|
||
public void NearestWallT_TwoCells_ReturnsNearer()
|
||
{
|
||
var nearCell = MakeWallCell(); // wall at Y=10
|
||
var farCell = MakeWallCell();
|
||
// Move farCell's transform to push it to Y=20.
|
||
farCell.WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0);
|
||
Matrix4x4.Invert(farCell.WorldTransform, out var inv);
|
||
farCell.InverseWorldTransform = inv;
|
||
|
||
var origin = new Vector3(0, 0, 1);
|
||
var direction = Vector3.UnitY;
|
||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
|
||
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test — expect failure**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellBspRayOccluder"`
|
||
Expected: FAIL (`CellBspRayOccluder` not found).
|
||
|
||
- [ ] **Step 3: Implement the occluder**
|
||
|
||
Create `src/AcDream.Core/Selection/CellBspRayOccluder.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
|
||
namespace AcDream.Core.Selection;
|
||
|
||
/// <summary>
|
||
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
|
||
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
|
||
/// (currently-loaded EnvCells with resolved polygon planes), returns
|
||
/// the nearest world-space <c>t</c> along the ray that hits any cell
|
||
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
|
||
/// all cells.
|
||
///
|
||
/// <para>
|
||
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
|
||
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
|
||
/// ray-triangle intersection; one test per triangle. Cells are
|
||
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
|
||
/// so the ray runs in cell-local space and the resolved-polygon
|
||
/// vertices don't need re-transformation per query.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// No BSP traversal — iterates every polygon in every cell. Cell count
|
||
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
|
||
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
|
||
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class CellBspRayOccluder
|
||
{
|
||
/// <summary>
|
||
/// Returns the nearest positive <c>t</c> such that
|
||
/// <c>origin + t * direction</c> intersects a polygon in any cell.
|
||
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
|
||
/// is intersected.
|
||
/// </summary>
|
||
/// <param name="direction">Need not be normalized; returned <c>t</c>
|
||
/// scales with direction length the same as a parametric ray.</param>
|
||
public static float NearestWallT(
|
||
Vector3 origin,
|
||
Vector3 direction,
|
||
IEnumerable<CellPhysics> loadedCells)
|
||
{
|
||
if (loadedCells is null) return float.PositiveInfinity;
|
||
|
||
float bestT = float.PositiveInfinity;
|
||
foreach (var cell in loadedCells)
|
||
{
|
||
if (cell?.Resolved is null) continue;
|
||
|
||
// Bring the ray into cell-local space ONCE per cell.
|
||
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
|
||
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
|
||
|
||
foreach (var (_, poly) in cell.Resolved)
|
||
{
|
||
// Triangulate the (possibly polygonal) face into a fan.
|
||
int n = poly.NumPoints;
|
||
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
|
||
continue;
|
||
|
||
for (int i = 1; i < n - 1; i++)
|
||
{
|
||
if (TryRayTriangle(
|
||
localOrigin, localDirection,
|
||
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
|
||
out var t)
|
||
&& t < bestT)
|
||
{
|
||
bestT = t;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return bestT;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Möller-Trumbore ray-triangle intersection. Returns true with
|
||
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
|
||
/// at a positive distance.
|
||
/// </summary>
|
||
private static bool TryRayTriangle(
|
||
Vector3 origin, Vector3 direction,
|
||
Vector3 v0, Vector3 v1, Vector3 v2,
|
||
out float t)
|
||
{
|
||
const float Epsilon = 1e-7f;
|
||
|
||
var edge1 = v1 - v0;
|
||
var edge2 = v2 - v0;
|
||
var pvec = Vector3.Cross(direction, edge2);
|
||
float det = Vector3.Dot(edge1, pvec);
|
||
|
||
// No two-sided handling here — picker should be permissive so
|
||
// a wall blocks regardless of which side the camera is on.
|
||
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
|
||
float invDet = 1f / det;
|
||
|
||
var tvec = origin - v0;
|
||
float u = Vector3.Dot(tvec, pvec) * invDet;
|
||
if (u < 0f || u > 1f) { t = 0f; return false; }
|
||
|
||
var qvec = Vector3.Cross(tvec, edge1);
|
||
float v = Vector3.Dot(direction, qvec) * invDet;
|
||
if (v < 0f || u + v > 1f) { t = 0f; return false; }
|
||
|
||
t = Vector3.Dot(edge2, qvec) * invDet;
|
||
return t > Epsilon;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests — expect green**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellBspRayOccluder"`
|
||
Expected: 4 tests passing.
|
||
|
||
---
|
||
|
||
## Task 13: Wire `WorldPicker.Pick` to use the occluder
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||
- Create: `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing integration test**
|
||
|
||
Create `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs`:
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using AcDream.Core.Selection;
|
||
using AcDream.Core.World;
|
||
using DatReaderWriter.Types;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Selection;
|
||
|
||
public class WorldPickerCellOcclusionTests
|
||
{
|
||
private static CellPhysics MakeWallAtY10()
|
||
{
|
||
var verts = new[]
|
||
{
|
||
new Vector3(-5, 10, -5),
|
||
new Vector3( 5, 10, -5),
|
||
new Vector3( 5, 10, 5),
|
||
new Vector3(-5, 10, 5),
|
||
};
|
||
var poly = new ResolvedPolygon
|
||
{
|
||
Vertices = verts,
|
||
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||
NumPoints = 4,
|
||
SidesType = SidesType.Front,
|
||
};
|
||
return new CellPhysics
|
||
{
|
||
BSP = null,
|
||
Resolved = new() { [0] = poly },
|
||
WorldTransform = Matrix4x4.Identity,
|
||
InverseWorldTransform = Matrix4x4.Identity,
|
||
};
|
||
}
|
||
|
||
private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
|
||
{
|
||
Id = guid,
|
||
ServerGuid = guid,
|
||
SourceGfxObjOrSetupId = 0,
|
||
Position = pos,
|
||
Rotation = Quaternion.Identity,
|
||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||
};
|
||
|
||
[Fact]
|
||
public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
|
||
{
|
||
var wall = MakeWallAtY10();
|
||
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
|
||
|
||
var result = WorldPicker.Pick(
|
||
origin: Vector3.Zero,
|
||
direction: Vector3.UnitY,
|
||
candidates: new[] { entity },
|
||
skipServerGuid: 0u,
|
||
cellOccluder: (origin, direction) =>
|
||
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
|
||
|
||
Assert.Null(result);
|
||
}
|
||
|
||
[Fact]
|
||
public void Pick_RaySphere_NoWall_HitsEntity()
|
||
{
|
||
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
|
||
|
||
var result = WorldPicker.Pick(
|
||
origin: Vector3.Zero,
|
||
direction: Vector3.UnitY,
|
||
candidates: new[] { entity },
|
||
skipServerGuid: 0u,
|
||
cellOccluder: null); // null occluder = no occlusion
|
||
|
||
Assert.Equal(0xABCDu, result);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test — expect compile failure (cellOccluder param doesn't exist yet)**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerCellOcclusion"`
|
||
Expected: build FAIL (`cellOccluder` param not on Pick).
|
||
|
||
- [ ] **Step 3: Add `cellOccluder` parameter to the legacy ray-sphere `Pick`**
|
||
|
||
In `src/AcDream.Core/Selection/WorldPicker.cs`, change the legacy `Pick` signature (line 88-95) from:
|
||
|
||
```csharp
|
||
public static uint? Pick(
|
||
Vector3 origin, Vector3 direction,
|
||
IEnumerable<WorldEntity> candidates,
|
||
uint skipServerGuid,
|
||
float maxDistance = 50f,
|
||
Func<uint, float>? radiusForGuid = null,
|
||
Func<uint, float>? verticalOffsetForGuid = null)
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
public static uint? Pick(
|
||
Vector3 origin, Vector3 direction,
|
||
IEnumerable<WorldEntity> candidates,
|
||
uint skipServerGuid,
|
||
float maxDistance = 50f,
|
||
Func<uint, float>? radiusForGuid = null,
|
||
Func<uint, float>? verticalOffsetForGuid = null,
|
||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||
```
|
||
|
||
Then, inside the method, BEFORE the foreach loop over candidates, capture the wall-t once:
|
||
|
||
```csharp
|
||
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
|
||
```
|
||
|
||
And inside the candidate loop, immediately before the `if (t < bestT)` line, add:
|
||
|
||
```csharp
|
||
if (t >= wallT) continue; // wall is between camera and entity
|
||
```
|
||
|
||
- [ ] **Step 4: Run the legacy-overload test — expect green**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerCellOcclusion.Pick_RaySphere"`
|
||
Expected: 2 tests passing.
|
||
|
||
- [ ] **Step 5: Add cellOccluder to the screen-rect Pick overload**
|
||
|
||
In `src/AcDream.Core/Selection/WorldPicker.cs`, change the screen-rect `Pick` signature (line 202-211) from:
|
||
|
||
```csharp
|
||
public static uint? Pick(
|
||
float mouseX, float mouseY,
|
||
Matrix4x4 view,
|
||
Matrix4x4 projection,
|
||
Vector2 viewport,
|
||
IEnumerable<WorldEntity> candidates,
|
||
uint skipServerGuid,
|
||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||
float inflatePixels = 8f)
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
public static uint? Pick(
|
||
float mouseX, float mouseY,
|
||
Matrix4x4 view,
|
||
Matrix4x4 projection,
|
||
Vector2 viewport,
|
||
IEnumerable<WorldEntity> candidates,
|
||
uint skipServerGuid,
|
||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||
float inflatePixels = 8f,
|
||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||
```
|
||
|
||
Inside the method, BEFORE the foreach over candidates, build the click ray and query the occluder. After computing the ray via the existing `BuildRay` helper (or `Matrix4x4.Invert(vp)` if `BuildRay` isn't directly callable due to viewport plumbing), use:
|
||
|
||
```csharp
|
||
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||
float wallT = cellOccluder?.Invoke(rayOrigin, rayDir) ?? float.PositiveInfinity;
|
||
// Convert wall t (world-space distance along normalized ray dir)
|
||
// to camera-space depth for comparison with `depth` from
|
||
// ScreenProjection.TryProjectSphereToScreenRect.
|
||
Vector3 wallPoint = float.IsPositiveInfinity(wallT)
|
||
? new Vector3(0, 0, 0)
|
||
: rayOrigin + rayDir * wallT;
|
||
float wallDepth = float.IsPositiveInfinity(wallT)
|
||
? float.PositiveInfinity
|
||
: Vector3.Transform(wallPoint, view).Z * -1f; // camera looks -Z; depth is positive
|
||
```
|
||
|
||
Inside the candidate loop, just before `if (depth < bestDepth)`:
|
||
|
||
```csharp
|
||
if (depth > wallDepth) continue;
|
||
```
|
||
|
||
**Note:** The camera-space depth math assumes the engine uses the System.Numerics row-vector convention (`view * projection`). Verify by reading the existing `ScreenProjection.TryProjectSphereToScreenRect` to see how `depth` is computed, and match.
|
||
|
||
- [ ] **Step 6: Wire the occluder from GameWindow**
|
||
|
||
In `src/AcDream.App/Rendering/GameWindow.cs` at the picker call (line 9134), add a `cellOccluder` argument that snapshots the currently-loaded cells:
|
||
|
||
```csharp
|
||
// Cluster A #86 (2026-05-19): occlude entities behind walls.
|
||
// Snapshot the currently-loaded EnvCells' physics — picker uses
|
||
// ray-vs-poly to gate selection through walls.
|
||
var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
|
||
foreach (var cellId in EnumerateLoadedEnvCellIds()) // see helper below
|
||
{
|
||
var cp = _physicsDataCache.GetCellStruct(cellId);
|
||
if (cp is not null) loadedCellPhysics.Add(cp);
|
||
}
|
||
|
||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||
view: camera.View, projection: camera.Projection,
|
||
viewport: viewport,
|
||
candidates: _entitiesByServerGuid.Values,
|
||
skipServerGuid: _playerServerGuid,
|
||
sphereForEntity: e => /* unchanged */ ...,
|
||
inflatePixels: 8f,
|
||
cellOccluder: (origin, direction) =>
|
||
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics));
|
||
```
|
||
|
||
Add a small helper above `OnPick` (or wherever fits):
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Cluster A #86 helper. Returns the EnvCell ids whose physics BSP
|
||
/// is currently cached and may occlude a picker ray. Authoritative
|
||
/// source TBD during integration — check whether `_cellVisibility`
|
||
/// exposes a public set, otherwise iterate `_pendingCellMeshes.Keys`
|
||
/// or the equivalent.
|
||
/// </summary>
|
||
private IEnumerable<uint> EnumerateLoadedEnvCellIds()
|
||
{
|
||
// Confirm authoritative source during integration. _physicsDataCache
|
||
// already has `_cellStruct` (private). Easiest path: add a public
|
||
// `IReadOnlyCollection<uint> CellStructIds` getter to PhysicsDataCache.
|
||
return _physicsDataCache.CellStructIds;
|
||
}
|
||
```
|
||
|
||
If `PhysicsDataCache` doesn't yet expose `CellStructIds`, add it. Edit `src/AcDream.Core/Physics/PhysicsDataCache.cs` near the existing `CellStructCount` property:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
|
||
/// EnvCell ids — used by <see cref="WorldPicker"/> to enumerate
|
||
/// occluder candidates without exposing the underlying dictionary.
|
||
/// </summary>
|
||
public IReadOnlyCollection<uint> CellStructIds => _cellStruct.Keys;
|
||
```
|
||
|
||
- [ ] **Step 7: Build + test**
|
||
|
||
Run: `dotnet build && dotnet test`
|
||
Expected: all green, including the two new `WorldPickerCellOcclusionTests`.
|
||
|
||
- [ ] **Step 8: Visual verify**
|
||
|
||
Re-launch the client. Mouse over the Inn's west exterior wall from open ground: cursor should NOT show a selection ring for any indoor entities. Mouse through the Inn's open door at an inside NPC: selection works.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Selection/CellBspRayOccluder.cs \
|
||
src/AcDream.Core/Selection/WorldPicker.cs \
|
||
src/AcDream.Core/Physics/PhysicsDataCache.cs \
|
||
src/AcDream.App/Rendering/GameWindow.cs \
|
||
tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs \
|
||
tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs
|
||
|
||
git commit -m "$(cat <<'EOF'
|
||
fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
|
||
|
||
WorldPicker.Pick previously had no occlusion test — any entity along
|
||
the click ray within maxDistance was a candidate, including ones
|
||
behind walls. Adds the CellBspRayOccluder static helper that
|
||
Möller-Trumbore-tests the click ray against every polygon in every
|
||
currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
|
||
Both Pick overloads gate candidate selection by that wall-t (legacy
|
||
ray-sphere via world-space `t`, screen-rect via camera-space depth).
|
||
|
||
PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
|
||
caller can iterate without needing the private cache dictionary.
|
||
|
||
Closes #86.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Close out docs
|
||
|
||
**Files:**
|
||
- Modify: `docs/ISSUES.md`
|
||
- Modify: `docs/plans/2026-04-11-roadmap.md`
|
||
- Modify: `CLAUDE.md` (only the "Currently working toward / current phase" line; otherwise leave alone)
|
||
- Create: `docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md`
|
||
|
||
- [ ] **Step 1: Move issues #84, #85, #86 to "Recently closed" in `docs/ISSUES.md`**
|
||
|
||
For each issue, change `**Status:** OPEN` to `**Status:** DONE`, add a `**Closed:** YYYY-MM-DD · <commit-sha>` line, and move the block to the "Recently closed" section at the bottom of the file (mirroring the format of other DONE entries).
|
||
|
||
- [ ] **Step 2: Add shipped entry to `docs/plans/2026-04-11-roadmap.md`**
|
||
|
||
Add a row to the "Recently shipped" table at the top of the roadmap doc. Format matches the existing "Indoor cell rendering Phase 2" row (which landed earlier today).
|
||
|
||
- [ ] **Step 3: Update CLAUDE.md "Currently in Phase..." paragraph**
|
||
|
||
Open `CLAUDE.md`. The block at the start of the "Roadmap discipline" section names the current phase. Update to reflect that Indoor walking Phase 1 shipped, and that the next item is Indoor walking Phase 2 (the visibility cluster — #78) OR a return to the M2 critical path (F.2/F.3/etc.) — pick per CLAUDE.md's work-order autonomy rule and announce in commit message.
|
||
|
||
- [ ] **Step 4: Write the shipped handoff doc**
|
||
|
||
Create `docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md` (replace placeholder with actual date). Format mirrors `docs/research/2026-05-14-b5-shipped-handoff.md`:
|
||
|
||
```markdown
|
||
# Indoor walking Phase 1 — shipped handoff
|
||
|
||
**Date:** YYYY-MM-DD
|
||
**Commits:** <sha-list>
|
||
**Closes:** ISSUES.md #84, #85, #86
|
||
|
||
## Probe evidence
|
||
|
||
(paste 3-5 representative `[indoor-bsp]` lines from `launch-cluster-a-capture.log`)
|
||
|
||
## Root causes
|
||
|
||
- **#84:** (one paragraph)
|
||
- **#85:** (one paragraph)
|
||
- **#86:** (one paragraph — WorldPicker had no cell-BSP test, pinned by code-reading not by capture)
|
||
|
||
## Files touched
|
||
|
||
(short list grouped by commit)
|
||
|
||
## Follow-up
|
||
|
||
(any new issues filed during this phase, e.g. an extension of the probe scope, or items deferred to Indoor walking Phase 2)
|
||
```
|
||
|
||
- [ ] **Step 5: Final build + test sweep**
|
||
|
||
Run: `dotnet build && dotnet test`
|
||
Expected: 0 errors, all green.
|
||
|
||
- [ ] **Step 6: Commit the docs**
|
||
|
||
```bash
|
||
git add docs/ISSUES.md \
|
||
docs/plans/2026-04-11-roadmap.md \
|
||
CLAUDE.md \
|
||
docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md
|
||
|
||
git commit -m "$(cat <<'EOF'
|
||
docs(phase): Indoor walking Phase 1 — shipped
|
||
|
||
Closes ISSUES.md #84, #85, #86. Adds shipped handoff doc with probe
|
||
evidence + root cause summaries. Roadmap and CLAUDE.md current-phase
|
||
pointer updated.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist (run after writing the plan; the engineer doesn't run this)
|
||
|
||
1. **Spec coverage**: every spec section maps to a task. ✓ §1-3 → Tasks 1-3 + facts; §4 components → Tasks 1-6 (probe), Task 12-13 (picker); §5 data flow → Tasks 7-11; §6 commit shape → Task 6 + 9 + 11 + 13 + 14; §7 files → File Structure table; §9 testing → Tasks 5, 12, 13; §10 acceptance → Task 14 Step 4 handoff doc.
|
||
2. **Placeholder scan**: post-capture tasks (8-11) intentionally carry parameterized fixes since the exact fix is unknown pre-capture; the runbook structure gives concrete commands + a decision tree. This is honest about the phase shape, not a placeholder.
|
||
3. **Type consistency**: `ProbeIndoorBspEnabled` (PhysicsDiagnostics) ↔ `ProbeIndoorBsp` (DebugVM) ↔ `ACDREAM_PROBE_INDOOR_BSP` (env var) ↔ "Indoor: BSP collision" (DebugPanel label) — verified consistent throughout. `CellBspRayOccluder.NearestWallT` signature consistent across Tasks 12 and 13.
|
||
4. **Acceptance**: matches spec §10 + design §13 risk #2 (the split-out option for #85 if the fix scope explodes).
|