diff --git a/CLAUDE.md b/CLAUDE.md index b12d1d1..4836d9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -776,7 +776,36 @@ acdream's plan lives in two files committed to the repo: acceptance criteria. Do not drift from the spec without explicit user approval. -**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices +**Indoor walking Phase 2 — Portal-based cell tracking shipped +2026-05-19.** Six commits: +- `1969c55` — CellBSP + Portals wired into CellPhysics (`PortalInfo` struct, `VisibleCellIds`) +- `aad6976` — `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`; `ResolveCellId` rename +- `069534a` — `BuildingPhysics` + `CheckBuildingTransit` for outdoor→indoor entry via `BldPortalInfo` +- `702b30a` — code-review polish (DRY cell-id derivation, `PortalFlags.ExactMatch` enum, docs) +- `3ffe1e4` — critical fix: pass foot-sphere center (`GlobalSphere[0].Origin`) not `CheckPos` to `ResolveCellId` +- `eb0f772` — `TryFindIndoorWalkablePlane` synthesizes indoor walkable plane from cell floor poly + +**#86** (click selection penetrates walls) — **CLOSED** (Phase 1 Cluster A). +**#84** (blocked by air indoors) — **FULLY CLOSED.** Spawn-in-building variant +closed by Phase 1 (Phase D AABB containment). Wall-block-from-inside variant +closed by Phase 2 (portal-graph traversal). +**#85** (pass through walls outside→in) — **CLOSED** by Phase 2. +`CheckBuildingTransit` promotes CellId via the building-shell portal graph +on outdoor→indoor entry; indoor-BSP collision fires from both sides. +**#87** (indoor portal-based cell tracking) — **CLOSED** by Phase 2. +**#88** (indoor static objects vibrate) — **FILED** (pre-existing, Medium). +**#89** (port `BSPQuery.SphereIntersectsCellBsp`) — **FILED** (Low, documented +approximation in `CheckBuildingTransit`). +Diagnostic infrastructure: `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`, +`[check-bldg]` probes all stay in place. +Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md). +Phase 1 handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](docs/research/2026-05-19-cluster-a-shipped-handoff.md). + +**Next phase is Claude's choice** per work-order autonomy. Candidates: +M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b — kill-a-drudge demo); +or the pre-existing "next phase candidates" list below. + +**Previously in Phase L.2 (Movement & Collision Conformance).** L.2a slices 1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c + **Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13; **Phase B.5** (ground-item pickup, F-key) shipped and visual-verified diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a9eb292..d96a360 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -308,9 +308,10 @@ to the second floor without getting stuck. --- -## #84 — Blocked by air indoors +## #84 — [DONE 2026-05-19] Blocked by air indoors -**Status:** OPEN +**Status:** DONE +**Closed:** 2026-05-19 **Severity:** HIGH (blocks indoor navigation) **Filed:** 2026-05-19 **Component:** physics, collision @@ -337,57 +338,126 @@ visible cell mesh. Possibilities: **Acceptance:** Walking through interior cell space hits collisions only where visible walls/furniture exist. +**Resolution (2026-05-19 partial · `c19d6fb`):** Phase D of Cluster A +extended `ResolveOutdoorCellId` in `PhysicsEngine.cs` with an indoor +cell-containment scan: when the player's world position falls inside any +cached EnvCell's AABB, `CellId` is promoted to that indoor cell, which +enables the `FindEnvCollisions` indoor-BSP branch. This resolved the +"spawn in building and be stuck above the floor" variant of #84 — +player's CellId now promotes to the interior cell on spawn-in, the floor +is walkable, and the player can move freely. The "invisible air obstacle" +symptom for rooms the player walks INTO from outside was tracked under #87 +and required portal-based cell tracking. + +**Resolution (2026-05-19 full · `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`):** +Indoor walking Phase 2 replaced AABB containment with portal-graph cell traversal +(`CellTransit.FindCellList` + `CheckBuildingTransit`). CellId now promotes to indoor +cells via portals and remains promoted during normal walking through doorways. Indoor +cell-BSP collision fires consistently. Indoor walkable plane synthesized from floor +poly (`TryFindIndoorWalkablePlane`) so the resolver tracks walkability correctly when +the player is standing on an indoor floor. User visually verified at Holtburg cottage: +walls block from inside, multi-room navigation works, walking outdoors through a door +works. Issue fully closed. + --- -## #85 — Pass through walls from outside→in +## #85 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Pass through walls from outside→in -**Status:** OPEN -**Severity:** HIGH (gameplay-breaking) +**Status:** DONE +**Closed:** 2026-05-19 +**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772` **Filed:** 2026-05-19 **Component:** physics, collision -**Description:** Approaching a building from the outside, the player +**Resolution (2026-05-19 · Indoor walking Phase 2):** The root cause (CellId never promoted +to the indoor cell during outdoor→indoor walking) was resolved by portal-graph cell +traversal in `CellTransit.CheckBuildingTransit`. Once `CellId` promotes to the indoor +cell, the indoor-BSP collision branch in `FindEnvCollisions` fires for approaches from +both inside and outside. User visually verified walls block from outside (player must +use the door portal to enter). See #87 and handoff: +[`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md). + +**Original description:** Approaching a building from the outside, the player can walk THROUGH walls into the interior — one-directional wall collision. From the inside trying to exit, the wall does block. -**Root cause / status:** Cell BSP polygons likely have one-sided -normals (front-facing only). Approach from the inside hits the front; -approach from the outside hits the back which BSP traversal treats as -"behind the plane" → no collision. Retail handles this via two-sided -collision polys or per-poly back-face handling. - -**Files:** -- `src/AcDream.Core/Physics/BSPQuery.cs` -- `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindObjCollisions` cell - branch). - -**Acceptance:** Walking into an inn wall from outside collides; player -must enter via the door portal. +The root cause was pinned (Cluster A 2026-05-19) as the same failure as +#84's remaining symptom — `CellId` wasn't promoted to the indoor cell +during normal outdoor→indoor walking because AABB containment was too +tight for threshold/doorway cells. Without CellId in the indoor cell, +the indoor-BSP collision branch in `FindEnvCollisions` never fired +regardless of approach direction. --- -## #86 — Click selection penetrates walls +## #87 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Indoor cell tracking uses AABB containment instead of portal traversal + +**Status:** DONE +**Closed:** 2026-05-19 +**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772` +**Filed:** 2026-05-19 +**Component:** physics + +**Resolution (2026-05-19 · Indoor walking Phase 2):** Portal-graph cell traversal +(`CellTransit.FindCellList` + `CheckBuildingTransit`) replaced the AABB containment +shortcut. Player CellId now correctly promotes to indoor cells via portals; +indoor cell-BSP collision branch fires consistently; walls block from inside. +Outdoor→indoor entry via `BuildingPhysics` + `BldPortalInfo` (`CheckBuildingTransit`) +wires the building-shell portal graph. Indoor walkable plane synthesized from the +cell's floor poly so the resolver tracks walkability during indoor movement (`TryFindIndoorWalkablePlane`). +See handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md). + +**Original description:** `PhysicsDataCache.TryFindContainingCell` promotes the +player's `CellId` to an indoor EnvCell when their world position falls +inside any cached cell's local AABB. This is too tight to keep `CellId` +promoted to an indoor cell during normal walking. Threshold/doorway cells +(the polys that sit at a room boundary) have AABB Z ranges of only ~0.2 m; +a standing player at local Z=0.46 m is OUTSIDE the AABB and containment +fails. Because `CellId` drifts back to the outdoor cell, the indoor-BSP +collision branch in `TransitionTypes.FindEnvCollisions` is gated out for +most movement, so walls don't block from inside the house and the floor +physics is unreliable. The retail fix is portal-based cell traversal — +when the player crosses a cell portal boundary, the cell ownership +propagates through portal connectivity data in `CEnvCell`. + +--- + +## #88 — Indoor static objects vibrate (bookshelves, open furnaces) **Status:** OPEN -**Severity:** MEDIUM +**Severity:** MEDIUM (visual jitter; doesn't block gameplay) **Filed:** 2026-05-19 -**Component:** input, interaction +**Component:** rendering, animation -**Description:** Clicking through a wall from the outside selects NPCs -and objects inside the building. The `WorldPicker` raycast doesn't -intersect cell BSP geometry. +**Description:** Static objects inside cells (bookshelves, open furnaces, possibly other interior props) show per-frame transform jitter / vibration. Pre-existing (user noticed before Phase 2 shipped). Likely candidates: -**Root cause / status:** `WorldPicker.BuildRay + Pick` (introduced in -Phase B.4) tests against entity AABBs and scenery BSPs but probably -not cell BSP. Outdoor NPCs are pickable because their entity AABB is -the test target; indoor NPCs are pickable from outside because the -wall isn't in the ray's intersection set. +1. `EntityScriptActivator.OnCreate/OnRemove` firing repeatedly as the player's CellId promotes/demotes near cell boundaries (less likely after Phase 2's portal-based tracking — but worth investigating). +2. Per-part transforms for cell-static `WorldEntity` instances getting recomputed each frame with floating-point drift. +3. Particle-emitter offsets accumulating instead of resetting. + +**Files to investigate:** +- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — OnCreate/OnRemove call patterns +- `src/AcDream.App/Rendering/GpuWorldState.cs` — entity transform updates per frame +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — per-batch transform composition + +**Acceptance:** Indoor static objects render stable (no per-frame jitter). + +--- + +## #89 — Port BSPQuery.SphereIntersectsCellBsp for retail-faithful CheckBuildingTransit + +**Status:** OPEN +**Severity:** LOW (Phase 2 ships with a documented approximation) +**Filed:** 2026-05-19 +**Component:** physics + +**Description:** Retail's `CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell` — a radius-aware sphere-vs-BSP test that returns Inside/Crossing/Outside. Phase 2's `CellTransit.CheckBuildingTransit` uses `BSPQuery.PointInsideCellBsp` (radius-less, tests only the sphere CENTER). Practical effect: outdoor→indoor entry fires ~sphereRadius (~0.48m) deeper into the doorway than retail. The sphereRadius parameter is plumbed through but currently unused. **Files:** -- `src/AcDream.App/Rendering/WorldPicker.cs` (or equivalent — check - Phase B.4b reference). +- `src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit` (line ~162) +- `src/AcDream.Core/Physics/BSPQuery.cs::PointInsideCellBsp` (line ~940) — existing point test to model the new sphere variant after -**Acceptance:** Clicking on a wall doesn't select NPCs behind it. +**Acceptance:** `CellTransit.CheckBuildingTransit` calls a new `BSPQuery.SphereIntersectsCellBsp(node, sphereCenter, sphereRadius)` that returns `Inside`/`Crossing`/`Outside`. Entry timing matches retail visually at the Holtburg cottage door. --- @@ -2918,6 +2988,23 @@ Unverified. The likely culprits, ranked by suspected probability: # Recently closed +## #86 — [DONE 2026-05-19 · 3764867 + 4e308d5] Click selection penetrates walls + +**Closed:** 2026-05-19 +**Commits:** `3764867` — fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker; `4e308d5` — test(picker): Cluster A #86 — screen-rect cell-occlusion tests +**Component:** input, interaction + +**Resolution:** `WorldPicker.Pick` now accepts a `cellOccluder` callback +(`CellBspRayOccluder`). Before returning a hit, both `Pick` overloads +consult the occluder's `NearestWallT` value; any candidate entity whose +ray parameter exceeds the nearest-wall intersection is filtered out. +The occluder is wired from `GameWindow` using the loaded `PhysicsDataCache` +cell structs. Entities behind walls from the camera's perspective are no +longer selectable. Screen-rect occlusion tests verify the filter across +several hit/miss scenarios. + +--- + ## #77 — [DONE 2026-05-18 · 3be7000] Auto-walk doesn't engage at walking range; pickup at walking range overshoots and snaps back **Closed:** 2026-05-18 diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index ba54f0c..9d5541b 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -71,6 +71,8 @@ | Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ | | Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger` with a Console-backed `ConsoleErrorLogger` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ | | C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` → `[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ | +| Indoor walking Phase 1 — BSP cluster (partial) | 2026-05-19. Probe + WorldPicker cell-BSP occlusion (#86 closed) + CellId promotion via AABB containment (partial #84 fix). Seven commits across 5 phases: `18a2e28` plan, `27d7de1` Phase A `[indoor-bsp]` probe + toggle, `3764867` Phase B CellBspRayOccluder in WorldPicker, `4e308d5` Phase B screen-rect tests, `c19d6fb` Phase D AABB containment + L.2e bare-low-byte fix, `fda6af7` Phase E `[cell-cache]` diagnostic, `1f11ba9` Phase E extended AABB/bsphere/poly-count fields. **#86 closed** (picker occlusion). **#84 partially closed** (spawn-in-building stuck-above-floor resolved; threshold/doorway walls remain open under #87). **#85 open** (wall pass-through root cause confirmed as same as #84 remaining symptom — CellId doesn't stay promoted during outdoor→indoor walking). **#87 filed** (portal-based indoor cell tracking — retail-faithful follow-up). `[indoor-bsp]` + `[cell-cache]` probes stay in place as scaffolding for the follow-up phase. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](../research/2026-05-19-cluster-a-shipped-handoff.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md). | Tests ✓ | +| Indoor walking Phase 2 — Portal-based cell tracking | 2026-05-19. Portal-graph traversal replaces Phase D's AABB containment. Six commits: `1969c55` CellBSP+Portals wired into CellPhysics; `aad6976` CellTransit.FindCellList + FindTransitCellsSphere + AddAllOutsideCells + ResolveCellId rename; `069534a` BuildingPhysics + CheckBuildingTransit for outdoor→indoor entry; `702b30a` code-review polish; `3ffe1e4` pass foot-sphere center to ResolveCellId (critical fix — was passing CheckPos instead of GlobalSphere[0].Origin, causing PointInsideCellBsp to return false at floor level); `eb0f772` TryFindIndoorWalkablePlane synthesizes walkable plane from cell floor poly so the resolver doesn't fall through to outdoor SampleTerrainWalkable. **Closes #87, #85, and the wall-pass-through portion of #84 (fully closes #84).** Files #88 (indoor static object vibration — pre-existing) and #89 (BSPQuery.SphereIntersectsCellBsp — approximation in CheckBuildingTransit). `[cell-transit]`, `[indoor-bsp]`, `[check-bldg]`, `[cell-cache]` probes stay in place. Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md). | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -224,7 +226,8 @@ Research: R9 + R12 + R13. - **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys. - **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload. -- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on L.2e** for trustworthy `cell_bsp`, indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`. +- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.** +- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on indoor portal-based cell tracking above** (and previously on L.2e) for trustworthy indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`. **Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight. diff --git a/docs/research/2026-05-19-cluster-a-shipped-handoff.md b/docs/research/2026-05-19-cluster-a-shipped-handoff.md new file mode 100644 index 0000000..b46fa1e --- /dev/null +++ b/docs/research/2026-05-19-cluster-a-shipped-handoff.md @@ -0,0 +1,256 @@ +# Indoor walking Phase 1 — BSP cluster (Cluster A) — handoff (2026-05-19) + +**Date:** 2026-05-19. +**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller). +**Predecessor:** Indoor lighting + rendering Phase 2 (fix) — floors now render in Holtburg Inn. Nine pre-existing indoor bugs surfaced the moment floors were visible; this cluster addresses the collision/interaction subset (#84, #85, #86) and adds diagnostic infrastructure for the follow-up portal-traversal phase. +**Plan:** [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md). + +--- + +## TL;DR + +Cluster A shipped **partially**. Three of the five planned phases (A, B, D) +produced real behavior changes; two (C — obstacle audit — and E — cell-cache +diagnostics) are diagnostic/research phases. The cluster's investigation +confirmed that the wall-collision failures (#84, #85) all root in one cause: +the player's `CellId` is never promoted to an indoor cell during normal +walking, so the indoor-BSP collision branch in `TransitionTypes.FindEnvCollisions` +never fires. Phase D implemented an AABB-containment shortcut that resolves +the specific "spawn inside a building and be stuck above the floor" case but +proved too tight to keep `CellId` promoted through threshold/doorway cells +during normal outdoor→indoor entry. + +**#86** (click selection penetrates walls) is **fully closed** — a clean, +self-contained fix in `WorldPicker`. + +**#84** is **partially closed** — the spawn-in-building symptom is gone; the +remaining wall-collision symptom during normal walking is tracked under the +new **#87**. + +**#85** remains **open**; its root cause is confirmed identical to #84's +remaining symptom and is also tracked under #87. + +**#87** (indoor portal-based cell tracking) is **filed** and ready for the +follow-up phase. + +--- + +## Commits + +| # | SHA | Subject | Phase | +|---|---|---|---| +| 1 | `18a2e28` | `docs(plan): implementation plan written` | Plan doc | +| 2 | `27d7de1` | `feat(physics): Cluster A — indoor BSP collision probe` | Phase A | +| 3 | `3764867` | `fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker` | Phase B | +| 4 | `4e308d5` | `test(picker): Cluster A #86 — screen-rect cell-occlusion tests` | Phase B follow-up | +| 5 | `c19d6fb` | `fix(physics): Cluster A #84 + #85 — indoor cell tracking` | Phase D | +| 6 | `fda6af7` | `feat(physics): Cluster A — cell-cache diagnostic` | Phase E (1st) | +| 7 | `1f11ba9` | `feat(diag): Cluster A — extend [cell-cache] with AABB + bsphere + recursive poly count` | Phase E (2nd) | + +**Build:** clean on all commits. +**Tests:** `dotnet test` shows the same 8 pre-existing failures in +`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged across +the entire cluster). All targeted test projects green. Phase B follow-up +adds screen-rect occlusion tests; Phase D adds `RegisterCellStructForTest` +helper used by caller-side tests. + +--- + +## What shipped + +### Phase A — `[indoor-bsp]` probe + +New `PhysicsDiagnostics.ProbeIndoorBspEnabled` toggle (env var +`ACDREAM_PROBE_INDOOR_BSP` + DebugPanel checkbox under +`ACDREAM_DEVTOOLS=1`). When enabled, logs one `[indoor-bsp]` line each time +`TransitionTypes.FindEnvCollisions` takes the indoor-cell branch — +i.e., when `CellId` is an EnvCell id and the BSP contains physics polys. The +probe serves as a presence detector: if `[indoor-bsp]` never fires during +indoor walking, the BSP is not being consulted at all. + +### Phase B — WorldPicker cell-BSP ray occlusion (closes #86) + +New `CellBspRayOccluder` class (in `src/AcDream.App/Rendering/`) computes +`NearestWallT`: the smallest ray parameter at which the pick ray intersects +any cached EnvCell BSP polygon. Both `WorldPicker.Pick` overloads now accept +an optional `cellOccluder` callback and filter out any hit candidate whose +ray T exceeds `NearestWallT`. The occluder is wired from `GameWindow` using +the `PhysicsDataCache` cell structs that Phase D also extends. + +Before Phase B: clicking through a wall from the outside selected NPCs/items +inside the building — `WorldPicker.BuildRay + Pick` (Phase B.4b) tested only +entity AABBs and scenery BSPs, not EnvCell BSP geometry. + +After Phase B: entities behind the nearest wall from the camera's perspective +are filtered out of the candidate set. Screen-rect unit tests verify the +filter across hit/miss/occlusion scenarios. + +### Phase D — AABB containment for indoor CellId (partial #84 fix) + +`PhysicsEngine.ResolveOutdoorCellId` is extended with an indoor +cell-containment scan. After resolving the outdoor cell, the method checks +whether the player's world position falls inside any cached `CellPhysics` +AABB; if so, `CellId` is promoted to that EnvCell. This enables the +`FindEnvCollisions` indoor-BSP branch. + +New `PhysicsDataCache.TryFindContainingCell(worldPos)` does the AABB scan. +New `CellPhysics.WorldAabb` caches the cell-local AABB in world space on +first call (transforms the BSP bounding sphere's local AABB by the cell +origin). New `RegisterCellStructForTest` helper allows unit test callers to +populate the cache directly. + +Also fixes the L.2e bare-low-byte preservation bug: `ResolveOutdoorCellId` +was silently truncating the player CellId to the low 16 bits; the fix +preserves the full 32-bit value. + +**What this solved:** player spawning inside a building (e.g., logging in +from a position inside Holtburg cottage) no longer sees `walkable=False` for +hundreds of resolves with world Z=94.000. Phase D promotes CellId to the +indoor cell, the floor's BSP polys are found, the player can move. + +**What this did NOT solve:** the `[indoor-bsp]` probe fires only 6 times +during an entire indoor walking session (all mid-jump, when the body happens +to be at a height that falls inside a room AABB). During normal walking on +the floor, the player's world Z is at the AABB floor level or lower — +outside the AABB for threshold/doorway cells that have only a 0.2 m Z range. +See Phase E evidence below. + +### Phase E — Cell-cache diagnostic infrastructure + +Two commits add `[cell-cache]` log output (env var +`ACDREAM_PROBE_CELL_CACHE`, also DebugPanel). For each EnvCell in the +physics cache, the probe logs: + +``` +[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14 + bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80) + bspOrigin=(0.00,0.00,0.00) bspRadius=9.97 +``` + +The extended second commit adds `bspTotalLeafPolys`, `bspUnmatchedIds`, +`bspOrigin`, and `bspRadius` fields to give a complete picture of cell +geometry from the physics cache perspective. This infrastructure stays in +place as scaffolding for the portal-traversal phase. + +--- + +## Issue status after Cluster A + +| Issue | Status | Notes | +|---|---|---| +| #84 Blocked by air indoors | OPEN (partial) | Spawn-in-building variant resolved by Phase D. Threshold/doorway wall-blocking remains open under #87. | +| #85 Pass through walls outside→in | OPEN | Root cause confirmed as same as #84 remaining symptom. See #87. | +| #86 Click selection penetrates walls | **CLOSED** | Phase B. `WorldPicker.Pick` + `CellBspRayOccluder`. | +| #87 Indoor portal-based cell tracking | OPEN (new) | Filed 2026-05-19. Retail-faithful fix via `CObjMaint::HandleObjectEnterCell`. | + +--- + +## Probe evidence — log file findings + +### `launch-cluster-a-capture.log` + +Initial probe run with `ACDREAM_PROBE_INDOOR_BSP=1`. Result: **zero +`[indoor-bsp]` lines** during outdoor walking and during approach to the +Holtburg cottage doorway. This was the first confirmation that the indoor-BSP +branch was entirely gated out. The player's CellId remained an outdoor cell +for all movement. + +### `launch-cluster-a-verify.log` + +Post-Phase-D run. Observed `[indoor-bsp]` lines **only during jump frames** +(6 total). When the player jumped inside the cottage, the body briefly rose +to a height inside the room AABB, CellId promoted to `0xA9B40143`, and the +indoor-BSP branch fired. On landing, the body returned to floor level, fell +outside the AABB, and CellId reverted to the outdoor cell. Confirmed that +AABB containment works for the room cell when the player is mid-air, but +fails at floor level. + +### `launch-cluster-a-cache-diag2.log` + +First `[cell-cache]` probe run (Phase E first commit). Showed all cached +cells with their physics poly counts and local AABBs. Confirmed 14 physics +polys in cell `0xA9B40143` (the room), indicating BSP geometry is present +and complete. Identified cell `0xA9B40146` as a 4-poly threshold cell. + +### `launch-cluster-a-cache-diag3.log` + +Extended `[cell-cache]` probe run (Phase E second commit). Full data: + +``` +[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14 + bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80) + bspOrigin=(0.00,0.00,0.00) bspRadius=9.97 +``` +Room cell: 2.80 m AABB height — works for mid-air player. + +``` +[cell-cache] id=0xA9B40146 physicsPolyCount=4 + aabbMin=(-11.60,2.80,-0.20) aabbMax=(-10.00,7.60,0.00) + bspRadius=2.3 +``` +Threshold/doorway cell: 0.20 m AABB Z range (from -0.20 to 0.00). A standing +player at local Z=0.46 m is outside this AABB. **This is why AABB containment +fails for normal walking through doorways.** + +Key conclusion: the geometry is correct and complete (14/14 polys match between +physics cache and BSP leaf count). The problem is purely in the cell-ownership +tracking mechanism, not the collision data itself. + +--- + +## Diagnostic infrastructure remaining in place + +Both probes stay committed and wired. They serve as scaffolding for the +portal-traversal follow-up phase: + +- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one + `[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell + branch. After portal traversal is implemented, this probe should fire + consistently whenever the player is indoors. + +- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all + cached EnvCell physics data (poly counts, BSP bounding sphere, AABB, + unmatched ID count). Useful for verifying that cell structs load correctly + and that portal connectivity data is present. + +Both are gated behind `PhysicsDiagnostics` static class (existing pattern +from L.2a). + +--- + +## Follow-up items for the portal-traversal phase + +**1. Implement portal-based indoor cell tracking (issue #87).** +Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's +`CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses +a cell portal boundary, `CellId` propagates through `CEnvCell` portal +connectivity data. PDB symbols in `docs/research/named-retail/acclient_2013_pseudo_c.txt` +and struct definitions in `docs/research/named-retail/acclient.h` lines +31715-31726 (`CCellStructure` shape). The retail reference implementation +is the right oracle — do not guess at the traversal algorithm. + +**2. Audit-trail note: add retail PDB symbol citations to `TryFindContainingCell`.** +The current implementation in `src/AcDream.Core/Physics/PhysicsDataCache.cs` +~line 261 is documented as a shortcut. The follow-up phase should add +the PDB symbol citation (e.g., `// retail: CObjMaint::HandleObjectEnterCell +// docs/research/named-retail/acclient_2013_pseudo_c.txt:XXXXX`) +per the Phase D code-review I1 note, so future readers know this is intentionally +replacing an interim implementation. + +**3. Consider renaming `ResolveOutdoorCellId` → `ResolveCellId`.** +The method now handles both outdoor and indoor cell resolution. The rename +is low-risk (one call site in `PhysicsEngine.cs`) and would reduce the +cognitive overhead for the next phase's author. Noted as a Phase D code-review +M2 suggestion — do it in the same commit as the portal-traversal implementation +to keep the rename and the semantic change together. + +--- + +## State at handoff + +- **Branch:** `claude/competent-robinson-dec1f4`, 7 commits of implementation/test/diagnostic work. +- **Build state:** `dotnet build -c Debug` clean. +- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp baseline). All new tests green. +- **Issues:** #86 CLOSED; #84 PARTIAL; #85 OPEN; #87 OPEN (new). +- **Diagnostic probes:** `[indoor-bsp]` + `[cell-cache]` active and wired. +- **Next:** portal-based indoor cell tracking (#87) or M2 critical path — Claude's choice per work-order autonomy. diff --git a/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md b/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md new file mode 100644 index 0000000..1365c70 --- /dev/null +++ b/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md @@ -0,0 +1,284 @@ +# Indoor walking Phase 2 — Portal-based cell tracking — handoff (2026-05-19) + +**Date:** 2026-05-19. +**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller). +**Predecessor:** Indoor walking Phase 1 — BSP cluster (Cluster A). Partially shipped 2026-05-19; closed #86 cleanly, filed #87 for the portal-traversal root cause. Diagnostic infrastructure (`[indoor-bsp]` + `[cell-cache]` probes) remained as scaffolding. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](2026-05-19-cluster-a-shipped-handoff.md). + +--- + +## TL;DR + +Phase 2 fully closes the indoor-walking story. Six commits replace Phase D's +AABB-containment shortcut with retail-faithful portal-graph cell traversal. +`CellId` now promotes to indoor cells via portals and remains promoted through +doorways, thresholds, and multi-room navigation. Indoor cell-BSP collision fires +consistently. A critical fix in commit 5 passes the foot-sphere center (not the +entity reference point) to `ResolveCellId`, which was the production failure that +made PointInsideCellBsp return false at floor level. Commit 6 adds +`TryFindIndoorWalkablePlane` so the walkability resolver doesn't fall through to +outdoor terrain when the player is inside. + +**Visual verification at Holtburg cottage (2026-05-19, user testing live ACE):** +- Walls block from inside — player cannot walk through cottage walls. +- Multi-room navigation via doorways works — `[cell-transit]` log shows `0xA9B40145 → 0x143 → 0x144 → 0x13F` chains. +- Walking back outdoors through a door works (post-walkable fix in commit 6). +- Cell tracking is robust through multiple indoor sessions. + +--- + +## Commits + +| # | SHA | Subject | +|---|---|---| +| 1 | `1969c55` | `feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics` | +| 2 | `aad6976` | `feat(physics): Phase 2 — port CellTransit + wire into ResolveCellId` | +| 3 | `069534a` | `feat(physics): Phase 2 — BuildingPhysics + CheckBuildingTransit` | +| 4 | `702b30a` | `refactor(physics): Phase 2 — code-review polish on BuildingPhysics commit` | +| 5 | `3ffe1e4` | `fix(physics): Phase 2 — pass foot-sphere center to ResolveCellId` | +| 6 | `eb0f772` | `fix(physics): Phase 2 — synthesize indoor walkable plane from cell floor` | + +**Build:** clean on all commits. +**Tests:** `dotnet test` shows the same 8 pre-existing failures in +`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged). All +new Phase 2 tests and the walkable-plane tests green. + +--- + +## What shipped + +### Commit 1 — CellBSP + Portals wired into CellPhysics + +New `PortalInfo` struct holds `PortalId`, `PortalPolygonIndex`, `PortalFlags`, +and `OtherCellId`. `CellPhysics` extended with: +- `CellBSP` — a third BSP tree (alongside `PhysicsBSP` and the render BSP) used + for point-in-cell tests. Retail: `CCellStructure::cell_bsp`. +- `Portals` — `IReadOnlyList` built from `envCell.CellPortals`. +- `PortalPolygons` — the visible polygons that portals reference (`cellStruct.Polygons`, + not `PhysicsPolygons`; portals reference the visible-geometry polygon list). +- `VisibleCellIds` — cells visible from this cell (used by `AddAllOutsideCells`). + +Phase D's `LocalAabbMin/Max` + `TryFindContainingCell` are deleted — they are now +superseded by the portal traversal in `CellTransit`. + +### Commit 2 — CellTransit + ResolveCellId + +New `CellTransit` static class implements the retail portal-neighbour walk. +Three public entry points: + +- **`FindTransitCellsSphere(sphereCenter, sphereRadius, startCell, cache)`** — + walks portal connectivity from `startCell` outward. For each portal, tests + whether the sphere overlaps the portal polygon (using `PointInsideCellBsp` on + the sphere center as an approximation — see issue #89 for the retail-faithful + sphere variant). Recurses into neighbour cells up to a depth limit. + +- **`AddAllOutsideCells(sphereCenter, blockId, cache, results)`** — for the + outdoor path: populates a 24m grid of outdoor cell ids around the sphere center + using `TerrainSurface.ComputeOutdoorCellId`. Mirrors retail's `add_all_outside_cells`. + +- **`FindCellList(sp, startCell, cache)`** — top-level driver. Determines whether + `startCell` is an indoor (EnvCell) or outdoor cell and dispatches accordingly. + Returns a list of candidate cell ids. + +`PhysicsEngine.ResolveOutdoorCellId` renamed to `ResolveCellId` (accepts +`sphereRadius` parameter). Body splits on indoor vs outdoor: +- **Indoor:** delegates to `FindCellList` and picks the candidate cell where + `PointInsideCellBsp` returns true for the sphere center. +- **Outdoor:** existing terrain-grid loop (`AddAllOutsideCells`). + +`BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` to `CellBSPNode?` +(dead code retype — no behavior change). Phase D's test file deleted. + +### Commit 3 — BuildingPhysics + CheckBuildingTransit + +Outdoor→indoor entry path via building-shell portal graph. New `BuildingPhysics` +class caches per-building portal data (`BldPortalInfo` structs with `PortalId`, +`OtherCellId`, `CellBSP`). `PhysicsDataCache` gains `_buildings` cache keyed by +building entity id. `GameWindow` iterates `lbInfo.Buildings` at landblock load and +populates the cache. + +`CellTransit.CheckBuildingTransit(sphereCenter, sphereRadius, blockId, physicsCache)` +ports retail's outdoor→indoor portal-graph entry: +1. For each building in the landblock's physics cache, test whether the sphere + center is inside the building's shell cell BSP (`PointInsideCellBsp`). +2. If inside, walk the building's portal graph to find the indoor EnvCell that + contains the sphere center. +3. Returns the EnvCell id (or 0 if no match). + +`PhysicsEngine.ResolveCellId`'s outdoor branch hooks `CheckBuildingTransit` after +the terrain-grid loop, so outdoor→indoor transition is detected during normal walking. + +### Commit 4 — Code-review polish + +Five items addressed from reviewer: +1. DRY cell-id derivation via existing `TerrainSurface.ComputeOutdoorCellId` + (removed inline duplicate in `CheckBuildingTransit`). +2. Named `PortalFlags.ExactMatch` enum instead of raw `0x01` literal. +3. Comment clarity on `ExactMatch` reserved field. +4. Doc comment on `CheckBuildingTransit` calling out the sphere-vs-point + divergence from retail's `sphere_intersects_cell` (see issue #89). +5. Rename misleading test method name. + +### Commit 5 — Critical fix: foot-sphere center to ResolveCellId + +**This was the production bug that prevented Phase 2 from working until the last run.** + +`ResolveCellId` was being called with `sp.CheckPos` (the entity's reference point +at feet level, world Z = terrain Z after the +0.02f bump) instead of +`sp.GlobalSphere[0].Origin` (the foot sphere CENTER, approximately +0.48m above terrain). + +Combined with the +0.02f Z-bump applied to cell origins in `PhysicsDataCache`, the +test point landed at cell-local Z = -0.02 m — just below the cell's floor — and +`PointInsideCellBsp` returned false for every cell. CellId never promoted to indoor +cells during normal walking despite `FindCellList` correctly finding the right +candidate cells. + +Passing the foot-sphere center (which sits 0.48m above the floor, well inside any +room cell) made portal-based cell tracking actually work in production. + +Also adds the `[check-bldg]` diagnostic line (logged when `CheckBuildingTransit` +returns a non-zero indoor cell id). + +### Commit 6 — TryFindIndoorWalkablePlane + +**Root cause of the post-Phase-2 falling-stuck bug.** + +When indoor cell-BSP returned OK (no wall collision), the code fell through to +outdoor `SampleTerrainWalkable` + `ValidateWalkable`. Outdoor terrain Z is below +the indoor floor (due to the +0.02f Z-bump), so `ValidateWalkable` computed the +player as floating well above terrain → not walkable → player stuck in the falling +animation when blocked by an indoor wall. + +New `TryFindIndoorWalkablePlane(worldPos, cellPhysics)`: finds the floor polygon +directly under the player's world position by testing `worldPos` against each +physics polygon's plane normal (upward-facing = floor) and building a `ContactPlane` +from it. Called from the indoor branch of `ResolveWithTransition` before the outdoor +terrain fallback. Returns true when a floor poly is found; the resolver uses the +synthesized plane for walkability. + +--- + +## Issue status after Phase 2 + +| Issue | Status | Notes | +|---|---|---| +| #84 Blocked by air indoors | **FULLY CLOSED** | Spawn-in-building variant: Phase D (Cluster A). Wall-block-from-inside + falling-stuck variants: Phase 2 commits 2, 5, 6. | +| #85 Pass through walls outside→in | **CLOSED** | `CheckBuildingTransit` + portal traversal. CellId promotes to indoor on outdoor→indoor entry. | +| #86 Click selection penetrates walls | CLOSED (Phase 1) | `WorldPicker.Pick` + `CellBspRayOccluder`. | +| #87 Indoor portal-based cell tracking | **CLOSED** | `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`. Portal-graph traversal replaces AABB containment. | +| #88 Indoor static objects vibrate | OPEN (new) | Pre-existing visual jitter on bookshelves/furnaces. Filed 2026-05-19. Medium severity. | +| #89 Port BSPQuery.SphereIntersectsCellBsp | OPEN (new) | `CheckBuildingTransit` uses `PointInsideCellBsp` (radius-less approximation) instead of retail's `sphere_intersects_cell`. Filed 2026-05-19. Low severity. | + +--- + +## Probe evidence — log file findings + +### `launch-phase2-verify3.log` + +First run that showed indoor cell-transits firing. `[cell-transit]` output +confirmed the portal traversal was finding indoor cells. `[indoor-bsp]` probe +fired consistently during indoor walking (not just during mid-jump frames as in +Cluster A). This log is the first evidence that `CellTransit.FindCellList` was +working correctly for room interiors, though outdoor→indoor entry was not yet +exercised. + +### `launch-phase2-verify4.log` + +Multi-room navigation run. `[cell-transit]` log shows +`0xA9B40145 → 0x143 → 0x144 → 0x13F` chains as the player walked between +rooms in the Holtburg cottage via doorways. Confirmed the `FindTransitCellsSphere` +recursive portal walk was promoting CellId correctly through threshold cells. +Walls blocked from inside in all rooms tested. + +### `launch-phase2-verify5.log` + +Walkable bug evidence run. After the outdoor→indoor transition was wired +(`CheckBuildingTransit`), the player could walk into the cottage from outside, +but colliding with an indoor wall produced a falling-stuck state (the `[indoor-bsp]` +probe fired for the wall collision, but `ValidateWalkable` returned false because +it was sampling outdoor terrain Z). This log captured the falling-stuck symptom +and the `SampleTerrainWalkable` fallthrough trace, motivating commit 6. + +### `launch-phase2-verify6.log` + +Post-walkable-fix verification run. After `TryFindIndoorWalkablePlane` was added: +- Outdoor→indoor entry works (player walks through doorway, CellId promotes). +- Indoor wall collision works (walls block, player doesn't pass through). +- Walking back outdoors through the door works (CellId demotes to outdoor cell). +- No falling-stuck state observed. User confirmed all three behaviors. + +--- + +## Diagnostic infrastructure remaining in place + +All four probes stay committed and wired. They serve as production diagnostics +and as debugging aids for follow-up issues: + +- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one + `[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell branch. + After Phase 2, this fires consistently whenever the player is indoors. Useful + for confirming the indoor-BSP path is active. + +- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all + cached EnvCell physics data (poly counts, BSP bounding sphere, AABB, unmatched + ID count, portal count). Useful for verifying cell struct loads and portal + connectivity. + +- **`ACDREAM_PROBE_CELL=1`** (existing L.2a slice 1): one `[cell-transit]` line + per `PlayerMovementController.CellId` change (old → new cell, world position, + reason tag). Essential for tracing indoor promotion/demotion sequences. + +- **`[check-bldg]`** (commit 5): logged by `ResolveCellId` when + `CheckBuildingTransit` returns a non-zero indoor cell id. Fires once per + outdoor→indoor transition detection. + +All gated behind `PhysicsDiagnostics` static class (existing pattern from L.2a). + +--- + +## Visual verification outcomes + +**2026-05-19, user testing live against local ACE at Holtburg.** + +| Scenario | Result | +|---|---| +| Walk into cottage wall from inside | Blocked ✓ | +| Walk between rooms via doorway | CellId transitions logged, multi-room navigation works ✓ | +| Walk from outside into cottage through door | Outdoor→indoor entry promoted CellId; indoor BSP collision active ✓ | +| Walk back outside through door | CellId demoted to outdoor cell; outdoor physics resumed ✓ | +| No falling-stuck after post-walkable fix | Confirmed ✓ | +| Robust across multiple indoor sessions | Confirmed ✓ | + +--- + +## Known follow-ups + +**#88 — Indoor static objects vibrate (bookshelves, open furnaces).** Pre-existing +visual jitter spotted before Phase 2 shipped. Medium severity. Candidates: repeated +`EntityScriptActivator.OnCreate/OnRemove` near cell boundaries, per-part transform +drift, or particle-emitter offset accumulation. Investigate in a follow-up session. + +**#89 — Port `BSPQuery.SphereIntersectsCellBsp`.** `CellTransit.CheckBuildingTransit` +currently uses `PointInsideCellBsp` (tests sphere CENTER only). Retail's +`CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell` +(radius-aware, returns Inside/Crossing/Outside). Practical effect: entry fires +~0.48m deeper into the doorway than retail. Low severity — visually acceptable. +The `sphereRadius` parameter is already plumbed through for when this is ported. + +**#80 — Indoor darkness (camera on 2nd floor goes very dark).** Still open. +Not in Phase 2's scope. Lighting / ambient-occlusion issue that predates indoor +rendering Phase 2. + +--- + +## State at handoff + +- **Branch:** `claude/competent-robinson-dec1f4`, 6 commits of Phase 2 work + (plus 7 from Phase 1 / Cluster A on the same branch). +- **Build state:** `dotnet build -c Debug` clean. +- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp + baseline). All targeted test projects green. +- **Issues:** #84, #85, #87 CLOSED. #86 CLOSED (Phase 1). #88, #89 OPEN (new). +- **Diagnostic probes:** `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`, + `[check-bldg]` all active and wired. +- **Next:** M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b — kill-a-drudge + demo) or other candidates per work-order autonomy in CLAUDE.md. diff --git a/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md b/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md new file mode 100644 index 0000000..6b5d446 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md @@ -0,0 +1,1846 @@ +# 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` 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, 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.` 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 `` 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; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal connection between two +/// EnvCells. Each carries a list of these, +/// mirroring retail's CCellStruct.portals array. +/// +/// +/// is a low-16 cell index (combined with the +/// owning landblock prefix at lookup time) or 0xFFFF to mean +/// "exit to outdoor world" (the player crosses this portal to leave +/// the building). +/// +/// +/// +/// indexes the OWNING cell's +/// dict (the visible-polygon +/// table, NOT which holds physics +/// polys). +/// +/// +/// +/// decodes bit 2 of : +/// (Flags & 2) == 0 → 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 find_transit_cells's +/// load-hint path for unloaded neighbours. +/// +/// +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; } + + /// Bit 2 of . See struct docstring. + 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`, init-only, default empty). +3. ADD: `PortalPolygons` (`Dictionary?`, init-only, default null). +4. ADD: `VisibleCellIds` (`IReadOnlySet`, 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? PhysicsPolygons { get; init; } + public VertexArray? Vertices { get; init; } + public required Matrix4x4 WorldTransform { get; init; } + public required Matrix4x4 InverseWorldTransform { get; init; } + public required Dictionary Resolved { get; init; } + + // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ─────── + + /// + /// The cell BSP used for + /// (point-in-cell tests). Separate tree from + /// (collision) and from the renderer's drawing-BSP. + /// Source: cellStruct.<CellBSP-property-name> at cache time. + /// Nullable: cells without a CellBSP cannot participate in portal + /// containment and are skipped by . + /// + public PhysicsBSPTree? CellBSP { get; init; } + + /// + /// Portal connections to neighbouring cells, in cell-local space. + /// Default: empty list. Source: envCell.CellPortals. + /// + public IReadOnlyList Portals { get; init; } = System.Array.Empty(); + + /// + /// Resolved VISIBLE polygons (from cellStruct.Polygons), + /// keyed by polygon id. Distinct from which + /// holds PhysicsPolygons. Portal lookup via + /// resolves through this dict. + /// Nullable when the cell has no visible polys (rare). + /// + public Dictionary? PortalPolygons { get; init; } + + /// + /// The cell ids visible from this cell (low-16 indexes, combined + /// with the owning landblock prefix). Populated from + /// envCell.VisibleCells. Unused this phase; reserved for the + /// optional find_cell_list visibility filter. + /// + public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); +} +``` + +**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(), + }; + + 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(), + Portals = new[] { portal }, + VisibleCellIds = new System.Collections.Generic.HashSet { 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 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(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(); + 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., // ← 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(); + 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.?.Root is null ? "null" : "ok")} " + + $"worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})")); + } +} +``` + +**Substitution:** `` 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(), + 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) +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(), + PortalPolygons = new Dictionary { [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(), + // 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(); + 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(); + 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(); + + 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; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, +/// ported from retail's CObjCell::find_cell_list family +/// (sphere variant for the player's single foot sphere). +/// +/// +/// Replaces Phase D's AABB containment. Uses the cell BSP for retail- +/// faithful point-in-cell tests via +/// . Walks the portal graph +/// starting from a given current cell to find which cells a moving +/// sphere overlaps. +/// +/// +/// +/// Reference pseudocode: +/// docs/research/acclient_indoor_transitions_pseudocode.md +/// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells +/// (sphere variant) at acclient_2013_pseudo_c.txt. +/// +/// +public static class CellTransit +{ + /// + /// Small radius padding matching retail's EPSILON usage in the + /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells"). + /// + private const float EPSILON = 0.02f; + + /// + /// Indoor portal-neighbour expansion. For each portal of + /// , test whether the sphere overlaps + /// the portal polygon's plane in cell-local space. If so, add the + /// neighbour cell to . + /// + /// + /// Ported from CEnvCell::find_transit_cells (sphere variant) + /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)". + /// + /// + /// The physics data cache (for neighbour lookups). + /// The cell whose portals are walked. + /// The full id (with landblock prefix) of + /// . Used to resolve neighbour ids by + /// combining the prefix with . + /// Player's foot-sphere center in world space. + /// Player's foot-sphere radius. + /// Set to add neighbour cell ids to. + /// Set to true if the sphere straddles an + /// exit portal (OtherCellId == 0xFFFF) — the caller should + /// then expand outdoor neighbour cells via . + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet 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(); + 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(); + 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 + /// + /// Outdoor neighbour expansion. Ported from + /// CLandCell::add_all_outside_cells (sphere variant) per the + /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)". + /// + /// + /// 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. + /// + /// + public static void AddAllOutsideCells( + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + HashSet 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 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(), + }; + 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 + /// + /// Top-level cell-tracking driver, ported from retail's + /// CObjCell::find_cell_list (sphere variant). + /// + /// + /// Walks the portal graph from , + /// finds the cell whose contains + /// the sphere center, and returns its full id (landblock-prefixed). + /// Falls back to when no candidate + /// matches. + /// + /// + /// + /// Pseudocode reference: + /// docs/research/acclient_indoor_transitions_pseudocode.md + /// §"Overall Driver: find_cell_list". + /// + /// + public static uint FindCellList( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId) + { + var candidates = new HashSet(); + 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(); + 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 +/// +/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a +/// given world position via retail's portal-graph traversal. Delegates +/// to . +/// +/// +/// Replaces Phase D's ResolveOutdoorCellId which used AABB +/// containment — see docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md +/// for the design. +/// +/// +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) +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; + +/// +/// 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 BuildingObj.Portals array +/// (per the pseudocode doc §"LandCell.find_transit_cells"). +/// +public sealed class BuildingPhysics +{ + public required Matrix4x4 WorldTransform { get; init; } + public required Matrix4x4 InverseWorldTransform { get; init; } + public required IReadOnlyList Portals { get; init; } +} + +/// +/// One building portal: the connection from a SortCell's BuildingObj to +/// an interior EnvCell. +/// +public readonly struct BldPortalInfo +{ + public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags, bool exactMatch) + { + OtherCellId = otherCellId; + OtherPortalId = otherPortalId; + Flags = flags; + ExactMatch = exactMatch; + } + + /// Full id of the interior EnvCell this portal connects to. + public uint OtherCellId { get; } + /// The portal id within the destination EnvCell. + 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 _buildings = new(); + +/// +/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list +/// for an outdoor landcell that contains a building stab. Used by +/// . +/// +public void CacheBuilding(uint landcellId, IReadOnlyList 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 BuildingIds => (IReadOnlyCollection)_buildings.Keys; + +/// Test helper, mirrors . +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(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterBuildingForTest(0xA9B40001u, building); + cache.RegisterCellStructForTest(0xA9B40100u, interiorCell); + + var candidates = new HashSet(); + 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 + /// + /// Outdoor→indoor entry path. Ported from retail's + /// BuildingObj::find_building_transit_cells + + /// EnvCell::check_building_transit. For each portal of the + /// outdoor building, look up the destination interior cell and test + /// whether the sphere overlaps it via . + /// If so, add the interior cell to . + /// + public static void CheckBuildingTransit( + PhysicsDataCache cache, + BuildingPhysics building, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet 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(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 + // building.Frame → world transform (or building.Transform — check Task 0) + var portals = new System.Collections.Generic.List(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) +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/-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/-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/-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) +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 `` 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. diff --git a/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md b/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md new file mode 100644 index 0000000..7284d91 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md @@ -0,0 +1,1297 @@ +# 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` — 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 + /// + /// Indoor walking Phase 1 (2026-05-19). When true, emits one + /// [indoor-bsp] line per + /// call made from 's indoor + /// cell-BSP branch. Captures the cell id, sphere local position, + /// resulting , 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). + /// + /// + /// While true, this also un-gates the diagnostic + /// side-channel inside + /// — see the OR'd condition at every poly + /// write site. Zero-cost when off. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_INDOOR_BSP=1. + /// Runtime-toggleable via DebugPanel. + /// + /// + /// + /// Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md. + /// + /// + 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 + /// + /// Indoor walking Phase 1 (2026-05-19). Runtime mirror of + /// PhysicsDiagnostics.ProbeIndoorBspEnabled (env var + /// ACDREAM_PROBE_INDOOR_BSP). Toggling here flips the + /// [indoor-bsp] probe live — no relaunch required. + /// Physics-side companion to the five render-side + /// ProbeIndoor* mirrors directly above. + /// + 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) +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 + + + +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 +git commit -m "$(cat <<'EOF' +fix(physics): Cluster A #84 — + +<3-5 line description citing the probe-line evidence> + +Closes #84. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Replace `` 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 +git commit -m "fix(physics): Cluster A #85 — + + + +Closes #85. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## 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()); + 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; + +/// +/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon +/// occlusion test. Given a ray and a set of +/// (currently-loaded EnvCells with resolved polygon planes), returns +/// the nearest world-space t along the ray that hits any cell +/// polygon — or if the ray clears +/// all cells. +/// +/// +/// Used by 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 +/// so the ray runs in cell-local space and the resolved-polygon +/// vertices don't need re-transformation per query. +/// +/// +/// +/// 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 Pick call is well under 1 ms. +/// +/// +public static class CellBspRayOccluder +{ + /// + /// Returns the nearest positive t such that + /// origin + t * direction intersects a polygon in any cell. + /// Returns if no cell polygon + /// is intersected. + /// + /// Need not be normalized; returned t + /// scales with direction length the same as a parametric ray. + public static float NearestWallT( + Vector3 origin, + Vector3 direction, + IEnumerable 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; + } + + /// + /// Möller-Trumbore ray-triangle intersection. Returns true with + /// t in if the ray hits the triangle + /// at a positive distance. + /// + 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(), + }; + + [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 candidates, + uint skipServerGuid, + float maxDistance = 50f, + Func? radiusForGuid = null, + Func? verticalOffsetForGuid = null) +``` + +to: + +```csharp + public static uint? Pick( + Vector3 origin, Vector3 direction, + IEnumerable candidates, + uint skipServerGuid, + float maxDistance = 50f, + Func? radiusForGuid = null, + Func? verticalOffsetForGuid = null, + Func? 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 candidates, + uint skipServerGuid, + Func sphereForEntity, + float inflatePixels = 8f) +``` + +to: + +```csharp + public static uint? Pick( + float mouseX, float mouseY, + Matrix4x4 view, + Matrix4x4 projection, + Vector2 viewport, + IEnumerable candidates, + uint skipServerGuid, + Func sphereForEntity, + float inflatePixels = 8f, + Func? 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(); + 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 + /// + /// 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. + /// + private IEnumerable EnumerateLoadedEnvCellIds() + { + // Confirm authoritative source during integration. _physicsDataCache + // already has `_cellStruct` (private). Easiest path: add a public + // `IReadOnlyCollection 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 + /// + /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached + /// EnvCell ids — used by to enumerate + /// occluder candidates without exposing the underlying dictionary. + /// + public IReadOnlyCollection 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) +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/-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 · ` 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/-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:** +**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/-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) +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). diff --git a/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md b/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md new file mode 100644 index 0000000..cb454ef --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md @@ -0,0 +1,427 @@ +# Indoor Portal-Based Cell Tracking — Design + +**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan. +**Scope:** Port retail's portal-graph cell traversal to replace Phase D's AABB containment shortcut. Closes ISSUES.md #87 and the remaining wall-collision parts of #84 and #85 (indoor walking — walls don't block, walking through doors doesn't update CellId). +**Predecessor:** Cluster A (`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`) shipped 2026-05-19. Phase D's AABB containment was a deliberate shortcut that the capture log proved insufficient for normal indoor walking. +**Retail pseudocode reference:** `docs/research/acclient_indoor_transitions_pseudocode.md` (2026-04-13) — the entire algorithm is already documented from ACE source cross-referenced against the retail header. This spec is the porting plan, not a re-derivation. + +--- + +## 1. What we know + +The 2026-04-13 research doc enumerates: + +- **`CObjCell::find_cell_list`** — the top-level driver, called every movement tick. Builds the list of cells a sphere overlaps + identifies the new "current cell" via point-in-cell. +- **`CEnvCell::find_transit_cells` (sphere variant)** — walks portal neighbors of an indoor cell. Adds neighbor cells whose `sphere_intersects_cell` returns `Inside` or `Crossing`. +- **`CEnvCell::check_building_transit`** — the outdoor→indoor entry path, invoked from `BuildingObj::find_building_transit_cells`. +- **`CLandCell::add_all_outside_cells`** — outdoor neighbor expansion on the 24m landcell grid. +- **`CCellStruct::point_in_cell`** → tail-calls `BSPTREE::point_inside_cell_bsp(cell_bsp, localPoint)`. The `cell_bsp` is a third BSP per cell, separate from `physics_bsp` and `drawing_bsp`. + +acdream already has: + +- **`BSPQuery.PointInsideCellBsp(node, point)`** at [src/AcDream.Core/Physics/BSPQuery.cs:940](src/AcDream.Core/Physics/BSPQuery.cs:940) — the canonical retail port of `point_inside_cell_bsp`. Currently wired but unused. +- **`LoadedCell.Portals`** (in `AcDream.App.Rendering`) — populated from `envCell.CellPortals` for the visibility renderer. Used for portal-BFS visibility, not collision. +- **`PhysicsDataCache.CacheCellStruct`** caches `CellStruct.PhysicsBSP` (collision BSP) + `PhysicsPolygons` + `VertexArray`. Does NOT currently cache `CellStruct.CellBSP` or portal data. + +Capture evidence (`launch-cluster-a-cache-diag3.log`, `launch-cluster-a-verify.log`): + +- Holtburg interior cells DO have full physics geometry (e.g. `0xA9B40143` has 14 polys all resolved, AABB `(-11.60, -1.60, 0.00) → (-6.20, 7.60, 2.80)`). +- Phase D's AABB containment fires for ~6 frames per session (mid-jump apex). The threshold/doorway cells with thin Z AABB (e.g. `0xA9B40146` with AABB Z `[-0.20, 0.00]`) never capture a standing player. +- Result: indoor cell-BSP collision branch fires intermittently; walls don't consistently block. + +--- + +## 2. Goal + +Port retail's portal-graph cell traversal so: + +1. The player's CellId tracks indoor cells correctly when walking inside a building. +2. Walking through a doorway (portal) promotes/demotes CellId correctly. +3. Walking into a building from outside (through a `BuildingObj` portal) promotes CellId to the right interior cell. +4. The indoor cell-BSP collision branch fires every frame the player is in an indoor cell, so walls block consistently. + +Out of scope: + +- Visibility-side portal traversal (`CellVisibility` / `LoadedCell.Portals`) — kept as-is. This phase is collision-side only. +- Two-sphere parts/AABB variant of `find_transit_cells` (used for creatures and large objects) — port only the player's single-sphere case for now. +- `VisibleCells` cleanup filter — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skip; the BSP-based point-in-cell already picks one winner. +- Multi-step sub-tick portal crossings within a single movement step — retail handles fast movement that crosses multiple portals; we'll port the basic single-crossing case and revisit if regressions surface. + +--- + +## 3. Architecture + +``` + Movement tick (per substep) + │ + ▼ + PhysicsEngine.ResolveCellId(worldPos, currentCellId) + │ + ▼ + ╔═══════════════════════════════════════════════╗ + ║ CellTransit.FindCellList ║ + ║ ║ + ║ current is indoor (low >= 0x0100)? ║ + ║ yes ─► seed cellArray with current EnvCell ║ + ║ no ─► add_all_outside_cells (LandCell) ║ + ║ + check_building_transit hits ║ + ║ ║ + ║ for each cell in cellArray (BFS-like): ║ + ║ cell.find_transit_cells(sphere) ──► add ║ + ║ neighbours via portal-graph walk ║ + ║ ║ + ║ for each cell in cellArray: ║ + ║ if PointInsideCellBsp(cell.CellBSP, lpos): ║ + ║ ─► newCurrentCell = cell, break ║ + ╚═══════════════════════════════════════════════╝ + │ + ▼ + sp.CheckCellId = newCurrentCell.Id (full prefix) + │ + ▼ + [indoor-bsp] probe fires correctly for indoor cells + Cell-BSP collision branch in FindEnvCollisions runs +``` + +The hot path runs once per `FindEnvCollisions` call. Portal-graph traversal walks the local neighborhood (current cell + 1-2 hops). Typical work per tick: ~5-10 BSP point tests, each O(BSP depth) ≈ O(log N). Cheaper than the current AABB scan over all loaded cells. + +--- + +## 4. Components + +### 4.1 Data types (extend / add) + +**`CellPhysics`** (extended — same record/class as today): + +| Field | Status | Source | +|---|---|---| +| `BSP` | existing | `cellStruct.PhysicsBSP` (collision) | +| `PhysicsPolygons` | existing | `cellStruct.PhysicsPolygons` | +| `Vertices` | existing | `cellStruct.VertexArray` | +| `WorldTransform` | existing | passed in from `GameWindow` | +| `InverseWorldTransform` | existing | computed | +| `Resolved` | existing | from `ResolvePolygons` | +| `LocalAabbMin` / `LocalAabbMax` | **delete** | Phase D AABB shortcut | +| **`CellBSP`** | **add** | `cellStruct.CellBSP` (third BSP for point-in-cell) | +| **`Portals`** | **add** | `IReadOnlyList` from `envCell.CellPortals` | +| **`VisibleCellIds`** | **add (optional, deferred)** | `envCell.VisibleCells` keys — for future cleanup filter; populated but unused in this phase | +| **`PortalPolygons`** | **add** | `cellStruct.Polygons` resolved by id (separate from `PhysicsPolygons`; portals reference visible polys) | + +**`PortalInfo`** (new readonly struct in `AcDream.Core.Physics`): + +```csharp +public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags) +{ + /// Bit 2 of Flags. See research doc §"PortalSide flag semantics". + public bool PortalSide => (Flags & 2) == 0; +} +``` + +**`BuildingPhysics`** (new sealed class in `AcDream.Core.Physics`): + +```csharp +public sealed class BuildingPhysics +{ + public required Matrix4x4 WorldTransform; + public required Matrix4x4 InverseWorldTransform; + public required IReadOnlyList Portals; +} + +public readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch); +``` + +One `BuildingPhysics` per outdoor landcell that contains a building stab. Used for outdoor→indoor entry. + +### 4.2 Caching (extend `PhysicsDataCache`) + +**`CacheCellStruct(envCellId, cellStruct, worldTransform)` — extended:** + +After the existing `Resolved = ResolvePolygons(...)` step, also populate the new fields: + +- `CellBSP = cellStruct.CellBSP` (verify field name during plan-writing; the DAT type may use `CellBSP`, `CellBsp`, or similar) +- `Portals = envCell.CellPortals.Select(cp => new PortalInfo(cp.OtherCellId, cp.PolygonId, cp.Flags)).ToList()`. **Decision:** change `CacheCellStruct`'s signature to `CacheCellStruct(uint envCellId, EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)` so portal data and other `EnvCell`-side fields are available in a single atomic call. One call site (`GameWindow.cs:5384`); change is mechanical. +- `VisibleCellIds = new HashSet(envCell.VisibleCells.Keys)` — populated but unused in this phase. +- `PortalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray)` — same shape as `Resolved` but built from the visible polygon table (since portal `PolygonId` indexes `Polygons`, not `PhysicsPolygons` — confirmed in `GameWindow.cs:5685`). + +**`CacheBuilding(landcellId, portals, buildingWorldTransform)` — new:** + +Invoked from `GameWindow.BuildInteriorEntitiesForStreaming` for each landcell that contains a building stab. The DAT data shape (BldPortals from `LandBlockInfo.Buildings`) needs verification during plan-writing. + +**Deleted methods:** + +- `PhysicsDataCache.TryFindContainingCell` — Phase D's AABB containment scan. +- The AABB-compute block inside `CacheCellStruct`. + +### 4.3 `CellTransit` (new static class) + +New file: `src/AcDream.Core/Physics/CellTransit.cs`. Pure-static, owns three public functions: + +```csharp +public static class CellTransit +{ + /// + /// Top-level driver. Ported from retail CObjCell::find_cell_list (sphere variant). + /// Returns the cell id whose CellBSP contains the sphere center, or the original + /// fallback cell id if no cell matches. + /// + public static uint FindCellList( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + out CellSet candidateSet); + + /// + /// Indoor portal-neighbour expansion. Ported from CEnvCell::find_transit_cells + /// (sphere variant). For each portal of `currentCell`, tests whether the sphere + /// could overlap the neighbour cell and adds it to `candidateSet`. + /// + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + Vector3 worldSphereCenter, + float sphereRadius, + ref CellSet candidateSet); + + /// + /// Outdoor→indoor entry. Ported from BuildingObj::find_building_transit_cells + + /// CEnvCell::check_building_transit. For each BldPortal of `buildingPhysics`, + /// resolves the destination EnvCell and tests whether the sphere is inside it + /// via PointInsideCellBsp. + /// + public static void CheckBuildingTransit( + PhysicsDataCache cache, + BuildingPhysics buildingPhysics, + Vector3 worldSphereCenter, + float sphereRadius, + ref CellSet candidateSet); + + /// + /// Outdoor neighbour expansion. Ported from CLandCell::add_all_outside_cells. + /// Computes the player's 2D position within the 24×24m landcell and adds + /// neighbour landcells whose boundary the sphere crosses. + /// + public static void AddAllOutsideCells( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + ref CellSet candidateSet); +} +``` + +`CellSet` is a small helper — either `HashSet` or a thin wrapper allocating a stackalloc-backed list. Pick during plan-writing based on allocation profile. + +### 4.4 `PhysicsEngine.ResolveCellId` (rename + rewrite) + +Replaces `PhysicsEngine.ResolveOutdoorCellId`. New name + signature extended with a `sphereRadius` argument (needed by `FindTransitCellsSphere` for the sphere-vs-portal-plane test). Body becomes: + +```csharp +internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId) +{ + if (fallbackCellId == 0) return 0; + if (DataCache is null) return fallbackCellId; + + uint newCellId = CellTransit.FindCellList( + DataCache, + worldPos, + sphereRadius, + currentCellId: fallbackCellId, + out _); + + return newCellId != 0 ? newCellId : fallbackCellId; +} +``` + +The caller (`Transition.FindEnvCollisions` at TransitionTypes.cs:1181) has `sp.GlobalSphere[0].Radius` available and passes it through. The other two `PhysicsEngine` call sites (`Resolve`, `ResolveWithTransition`) need to plumb the sphere radius from their respective callers; the existing physics types carry it. + +Three existing call sites of `ResolveOutdoorCellId` get renamed AND updated to pass the sphere radius: + +- `PhysicsEngine.ResolveWithTransition` (line ~729) +- `PhysicsEngine.Resolve` (line ~287) +- `Transition.FindEnvCollisions` (TransitionTypes.cs:1181) + +### 4.5 Bootstrap on teleport + +When the player teleports to a new cell (server-provided cell id from the network), the existing teleport path stores the cell id and triggers `ResolveCellId` on the next physics tick. Two cases: + +- **Server-provided cell id is loaded** in our cache → `FindCellList` starts from that cell, walks the portal graph, point-in-cell determines the actual current cell. Works correctly. +- **Server-provided cell id is NOT yet loaded** → `FindCellList` falls through to `AddAllOutsideCells` (treats as outdoor). The next tick after streaming loads the cell, the portal-graph walk picks it up. + +Acceptance for teleport: player teleporting to an indoor cell (e.g. Holtburg cottage interior) gets the correct CellId on the first or second tick after spawn. Documented as a known edge case if the streaming takes more than one tick. + +--- + +## 5. Data flow + +### Landblock load (one-time per landblock) + +``` +GameWindow.BuildInteriorEntitiesForStreaming(landblockId, lbInfo) + │ + ▼ + For each EnvCell: + envCell = _dats.Get(envCellId) + cellStruct = environment.Cells[envCell.CellStructure] + cellTransform = R(envCell.Position.Orientation) * T(envCell.Position.Origin + lbOffset + Z-bump) + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform) + │ populates: BSP, CellBSP, PhysicsPolygons, Vertices, WorldTransform, + │ InverseWorldTransform, Resolved, Portals, PortalPolygons, + │ VisibleCellIds + + For each landcell containing a building (LandBlockInfo.Buildings): + _physicsDataCache.CacheBuilding(landcellId, building.Portals, buildingTransform) + │ populates: BldPortals list + buildingWorldTransform +``` + +### Movement tick (per substep) + +``` +PhysicsEngine.ResolveWithTransition starts + │ + ▼ + Transition.FindEnvCollisions: + sp.CheckCellId = ... (current cell estimate) + sphereRadius = sp.GlobalSphere[0].Radius + newCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId) + if newCellId != sp.CheckCellId: + sp.SetCheckPos(sp.CheckPos, newCellId) + │ + ▼ + Cell-BSP branch fires if sp.CheckCellId & 0xFFFF >= 0x0100 + ├── BSPQuery.FindCollisions(cellPhysics.BSP, ...) ← walls collide here + └── [indoor-bsp] probe emits a log line + │ + ▼ + Outdoor terrain collision (unchanged) +``` + +--- + +## 6. Commit shape (preview) + +1. **`feat(physics): wire CellBSP + Portals + PortalPolygons into CellPhysics`** — extend `CellPhysics` shape; update `CacheCellStruct` signature to accept `envCell` (for portal data); deletes `LocalAabbMin/Max` fields and the AABB compute. Tests verify a synthetic `EnvCell` with portals + CellBSP populates the new fields correctly. +2. **`feat(physics): port find_transit_cells sphere variant for indoor portals`** — new `CellTransit.FindTransitCellsSphere`. Tests use a synthetic two-cell portal pair to verify a sphere crossing the portal poly adds the neighbour cell. +3. **`feat(physics): port BuildingPhysics + check_building_transit for outdoor→indoor`** — `CacheBuilding` + `CellTransit.CheckBuildingTransit`. GameWindow wiring at landblock load. Tests verify a sphere overlapping a building portal triggers indoor-cell add. +4. **`feat(physics): port add_all_outside_cells for landcell neighbours`** — `CellTransit.AddAllOutsideCells`. Tests cover the 24×24m grid boundary cases. +5. **`feat(physics): port find_cell_list driver, wire into ResolveCellId, delete AABB containment`** — top-level driver; rename `ResolveOutdoorCellId` → `ResolveCellId` and update 3 call sites; delete `PhysicsDataCache.TryFindContainingCell`. Rewrites the 4 Phase D tests (`ResolveOutdoorCellIdIndoorContainmentTests`) to use the portal traversal mechanism. +6. **Capture session (user-driven)** — walk the Holtburg cottage with `ACDREAM_PROBE_INDOOR_BSP=1` + `ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_CELL_CACHE=1`. Verify all four acceptance criteria below. +7. **`docs(phase): Indoor portal cell tracking shipped`** — closes #87 and the remaining wall-collision parts of #84 + #85; updates ISSUES.md, roadmap, CLAUDE.md; writes shipped-handoff doc. + +--- + +## 7. Files touched + +**Modified:** + +- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — `CellPhysics` shape extended; `CacheCellStruct` signature change; new `CacheBuilding`; deleted `TryFindContainingCell` + AABB compute. +- `src/AcDream.Core/Physics/PhysicsEngine.cs` — rename `ResolveOutdoorCellId` → `ResolveCellId`; body rewritten to call `CellTransit.FindCellList`; 3 call sites in this file updated. +- `src/AcDream.Core/Physics/TransitionTypes.cs` — call site update at line 1181. +- `src/AcDream.App/Rendering/GameWindow.cs` — pass `envCell` into the extended `CacheCellStruct`; wire `CacheBuilding` at landblock load. + +**New:** + +- `src/AcDream.Core/Physics/CellTransit.cs` — the new static class with `FindCellList`, `FindTransitCellsSphere`, `CheckBuildingTransit`, `AddAllOutsideCells`. +- `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs` — indoor portal traversal. +- `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs` — outdoor→indoor entry. +- `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs` — outdoor neighbours. +- `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs` — integration tests. + +**Rewritten:** + +- `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` — renamed and ported to test the portal-based replacement. + +**Closed in ISSUES.md:** + +- #87 (indoor cell tracking via AABB containment) — fully closed by this phase. +- #85 (pass through walls outside→in) — closed; the outdoor→indoor entry path through `BuildingObj` handles this. +- #84 (blocked by air indoors) — the wall-pass-through portion that remained after Phase D is closed here. + +--- + +## 8. Error handling + +- **Cell loaded without `CellBSP`** — `PointInsideCellBsp(null, pt)` per its current contract returns `true`, which over-matches. Add an explicit `cellPhysics.CellBSP?.Root == null` skip in `FindTransitCellsSphere` and in `FindCellList`'s containment loop. The cell is treated as "not findable" until its BSP loads. +- **Portal references an unloaded `OtherCellId`** — retail handles this with a "load hint" path that adds a null-cell entry for the streamer. We skip the add and continue; the next physics tick after streaming loads the cell picks it up. Document the one-tick latency as a known edge case. +- **Player teleports to a cell ID with no cached `CellPhysics`** — fall back to `AddAllOutsideCells` (treat as outdoor) for that tick; the next tick after streaming loads the cell, portal traversal takes over. +- **No try/catch swallows.** If the BSP traversal hits a malformed tree, the underlying `BSPQuery` asserts (Debug) or returns `false` (Release). + +--- + +## 9. Testing + +### Unit tests (per commit) + +- **`CellPhysicsCellBspWiringTests`** — `CacheCellStruct` populates `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`. +- **`CellTransitFindTransitCellsSphereTests`** — synthetic two-cell portal pair: + - Sphere overlapping portal poly → adds neighbour. + - Sphere far from portal → doesn't add neighbour. + - Sphere on wrong side of portal (per `PortalSide`) → doesn't add neighbour. + - Sphere crossing exit portal (`OtherCellId == 0xFFFF`) → sets `checkOutside = true`. +- **`CellTransitCheckBuildingTransitTests`** — outdoor sphere overlapping building portal plane + inside destination cell's CellBSP → adds the indoor cell. +- **`CellTransitAddAllOutsideCellsTests`** — sphere at boundary X+Y, +X−Y, −X+Y, −X−Y of a 24m cell → 1, 2, or 4 cells in the result set. +- **`CellTransitFindCellListTests`** — integration: + - Indoor seed → returns matching indoor cell after portal walk. + - Outdoor seed → returns matching landcell. + - Outdoor seed near building portal → returns indoor cell via `check_building_transit`. + - Indoor seed crossing exit portal → returns outdoor landcell. + +### Rewritten tests + +- The four `ResolveOutdoorCellIdIndoorContainmentTests` (Phase D) — same scenarios, but using the portal-traversal mechanism rather than synthetic AABB-only cells. Some may merge with `CellTransitFindCellListTests`. + +### Live test (user-driven) + +Same launch incantation as Phase E: + +```powershell +$env:ACDREAM_PROBE_INDOOR_BSP = "1" +$env:ACDREAM_PROBE_RESOLVE = "1" +$env:ACDREAM_PROBE_CELL = "1" +$env:ACDREAM_PROBE_CELL_CACHE = "1" +$env:ACDREAM_DEVTOOLS = "1" +``` + +Walk the Holtburg cottage end-to-end. Verify all four acceptance criteria below. + +--- + +## 10. Acceptance + +1. **Indoor walking** — Player walks inside the Holtburg cottage freely; walls block from inside (current bug fixed); furniture still collides (no regression from per-object collision). +2. **Outdoor→indoor** — Player walks toward the cottage door from outside; CellId promotes to an indoor cell when crossing the doorway; walls beyond the door block. +3. **Indoor→outdoor** — Player walks back out through the door; CellId demotes to the outdoor landcell; outdoor terrain collision resumes; ACE doesn't report cell-state desync. +4. **Indoor→indoor** — Player walks from one room to another through an interior doorway; CellId transitions correctly between EnvCells; no momentary "stuck on portal plane" issues. +5. **`[indoor-bsp]` probe fires consistently** during indoor walking — not just during jumps (the Phase D failure mode). +6. **`dotnet build` + `dotnet test`** green with the new test suite. Pre-existing baseline of 8 failures unchanged. + +--- + +## 11. Out of scope (deferred / explicit non-goals) + +- **Parts/AABB variant of `find_transit_cells`** — used for creatures and large objects with multi-part bounding boxes. Only the player's single-sphere case is in scope here; the AABB variant ports as a follow-up if remote-entity cell tracking proves broken. +- **`VisibleCells` cleanup filter** — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skipped; the BSP point-in-cell already picks one winner. Data is populated for future use. +- **Multi-portal crossings within a single movement step** — retail's resolver handles fast movement crossing multiple portals via the per-substep loop. We rely on the per-substep loop being fine-grained enough; if a regression surfaces, address as a follow-up. +- **Unification with `LoadedCell.Portals` in `AcDream.App.Rendering`** — two parallel portal stores remain (Core for collision, App for visibility). Future cleanup could unify them, but not in this phase. +- **`CellTransit` for moving entities other than the player** — the function works for any sphere, but only the player's resolve path is wired this phase. Remote-entity cell tracking remains as-is. + +--- + +## 12. Risks + +1. **DAT field name mismatch.** The pseudocode doc references `CellStruct.CellBSP` but DatReaderWriter may name it differently (e.g. `cell_bsp`, `CellBsp`, `CellTree`). Verify at plan-writing time by reading DatReaderWriter's `CellStruct.cs` (NuGet source). If the field is missing entirely, file a sub-phase to extend DatReaderWriter — but this is unlikely given the dat format includes the BSP. +2. **`BuildingObj.Portals` structure differs from indoor portals.** Retail's `BldPortal` has more fields (`OtherPortalId`, `ExactMatch`). The DAT representation lives under `LandBlockInfo.Buildings[...]`; verify the field shape at plan-writing time. +3. **Sphere radius plumbing.** `FindTransitCellsSphere` needs the player's sphere radius to test against the portal plane. The caller (`Transition.FindEnvCollisions`) has access via `sp.GlobalSphere[0].Radius`; plumb it through `ResolveCellId`'s signature in the same commit that wires the call. +4. **Rename cost.** Renaming `ResolveOutdoorCellId` → `ResolveCellId` cascades through 4 call sites + test names + commit messages. Bundling the rename with the wiring commit keeps the change atomic; spreading it across commits creates a transient state where the function name doesn't match its behavior. +5. **Phase D test rewrites.** The 4 Phase D tests assert AABB-containment behavior that no longer exists. Rewriting them to use the portal-traversal mechanism requires synthetic test fixtures with portals + CellBSP — more setup boilerplate. Acceptable cost; integration coverage improves. + +--- + +## 13. Phase name + roadmap placement + +**Proposed name:** "Indoor portal-based cell tracking" (sometimes abbreviated "Indoor walking Phase 2" since it follows Cluster A / Indoor walking Phase 1). + +**Roadmap placement:** add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Sits in front of any remaining indoor-rendering polish (issues #78, #79-#82) since indoor walking is the gating issue. + +**Milestone:** still parallel to M2 (Kill a drudge). Completing indoor walking unblocks demos that involve buildings (e.g. talking to interior NPCs, picking up items from inside shops). diff --git a/docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md b/docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md new file mode 100644 index 0000000..c57a860 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md @@ -0,0 +1,313 @@ +# Indoor Walking Phase 1 — BSP cluster (#84 / #85 / #86) + +**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan. +**Scope:** Diagnostic-first investigation pass across the three "indoor walking is broken" bugs that share a cell-BSP / picker root-cause cluster. Surface evidence with a single probe + one capture session, then ship surgical fixes (one commit per issue). +**Predecessors:** +- Indoor cell rendering Phase 1 (`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`) — the five `[indoor-*]` render-side probes. +- Indoor cell rendering Phase 2 (`docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md`) — silent-failure surfacing + WB Setup-prefix guard. Made floors render. +- Handoff: `docs/research/2026-05-19-indoor-followup-handoff.md`. + +The indoor cell rendering Phase 1+2 pair made floors render. The moment floors rendered, nine pre-existing indoor bugs (`docs/ISSUES.md` #78-#86) became user-observable. This phase tackles the **BSP cluster** subset: #84, #85, #86. + +`#78` (outdoor stabs visible through floor) is in the same handoff cluster but a fundamentally different code path (render-side visibility / stencil), so it's deferred to a separate phase. `#79-#83` (lighting / terrain / stairs) are in different clusters. + +--- + +## 1. What we know from the code + +Pre-investigation reads (2026-05-19) of the three issue surfaces: + +### `#84` (blocked by air indoors) — cell BSP IS consulted + +The handoff hypothesized "cell BSP isn't being used". Code reading says otherwise: + +- **Cell BSP IS cached.** `PhysicsDataCache.CacheCellStruct` ([src/AcDream.Core/Physics/PhysicsDataCache.cs:131](src/AcDream.Core/Physics/PhysicsDataCache.cs:131)) stores `BSP`, `PhysicsPolygons`, `Vertices`, `WorldTransform`, `InverseWorldTransform`, and pre-resolved polygons (planes computed at cache time). +- **Cell BSP IS consulted in collision.** `Transition.FindEnvCollisions` ([src/AcDream.Core/Physics/TransitionTypes.cs:1188-1241](src/AcDream.Core/Physics/TransitionTypes.cs:1188)) has an explicit indoor branch gated on `cellLow >= 0x0100` that: + 1. Looks up `cellPhysics` via `engine.DataCache.GetCellStruct(sp.CheckCellId)`, + 2. Transforms the player's sphere to cell-local space via `InverseWorldTransform`, + 3. Calls `BSPQuery.FindCollisions` with the cell's pre-resolved polys, + 4. Returns `cellState` if `!= OK`. + +So #84's root cause is not "wiring missing". It's one of: (a) extra physics-only polys with no visible counterpart, (b) `+0.02f` Z-bump misalignment between cellTransform (applied to physics) and player Z (computed from terrain), (c) `BSPQuery` returning false positives at certain poly side-types, (d) `cellTransform` quaternion error on rotated cells. Capture data will pin which. + +### `#85` (pass through walls outside→in) — likely asymmetric path + +Walking outside-in keeps `CheckCellId` as the outdoor land cell (low byte `0x00xx-0x00FF`), so the indoor cell-BSP branch at TransitionTypes.cs:1192 is **gated out by design** (`cellLow >= 0x0100` is false). The only collision tested on the outside-in approach is: + +- **Terrain** (always tested), +- **Outdoor stab BSPs** ([`PhysicsDataCache.GetGfxObj`](src/AcDream.Core/Physics/PhysicsDataCache.cs) for `LandBlockInfo.Objects`) — building stab is hit via `FindObjCollisions`. + +L.2d slice 1+1.5 ported `CBuildingObj` collision (per CLAUDE.md), so the outer building shell SHOULD be hit. If #85 reproduces, hypotheses: + +1. The outdoor stab BSP for the Inn covers floor+roof but is missing wall polys (authoring shape — retail's interior cells own the walls, outdoor shell is a partial envelope). +2. The outdoor stab BSP has wall polys but with one-sided normals; outside approach hits the back face which BSP treats as "behind plane" → no collision (`feedback_no_patching_collision` memory's faithful-port rule means we'd need to follow retail's handling). +3. The L.2g dynamic-physics-state flag work doesn't include outdoor building shells in the collision sweep for the player's CheckCellId. +4. **Retail's actual behavior** may be that outside-in BSP probing queries the EnvCell's BSP across the cell boundary — retail's `CCellStructure::find_env_collisions` may walk neighbor-cell BSPs. + +### `#86` (click selection penetrates walls) — root cause definitively pinned by code reading + +`WorldPicker.Pick` ([src/AcDream.Core/Selection/WorldPicker.cs:88-160](src/AcDream.Core/Selection/WorldPicker.cs:88), and the screen-rect overload at line 202) is **pure ray-sphere against entity AABBs**. There is no cell BSP test, no scenery BSP test, no terrain test. Any entity along the ray within `maxDistance` is a candidate; nothing occludes. + +No probe needed for #86. Fix is structural: add a cell-BSP ray-poly occlusion test that runs once per `Pick` call and culls entities whose ray-distance exceeds the nearest wall hit. + +--- + +## 2. The three issues + +| # | Title | Code path | Fix shape | +|---|---|---|---| +| #84 | Blocked by air indoors | `Transition.FindEnvCollisions` cell branch | TBD — pinned by probe capture | +| #85 | Pass through walls outside→in | `FindObjCollisions` outdoor-stab path or cross-cell BSP probing | TBD — pinned by probe capture | +| #86 | Click selection penetrates walls | `WorldPicker.Pick` (both overloads) | Add cell-BSP ray-poly occlusion test | + +--- + +## 3. Architecture + +``` + [indoor-bsp] probe + ↓ + ┌───────────────────┴────────────────────┐ + ▼ ▼ + Movement path Picker path + (FindEnvCollisions cell branch) (WorldPicker.Pick) + │ │ + ├─→ #84: blocked by air └─→ #86: click through walls + └─→ #85: pass through walls (cause already pinned by code reading) + (cause TBD — needs capture) +``` + +The probe spans only the movement path. #86's diagnosis is already known; its fix is independent of the capture and can land in parallel. + +--- + +## 4. Components + +### Component 1 — `PhysicsDiagnostics.IndoorBspEnabled` + +New static toggle on `AcDream.Core.Physics.PhysicsDiagnostics`. Mirrors the existing `ResolveProbeEnabled` / `CellProbeEnabled` pattern: + +- Backed by `ACDREAM_PROBE_INDOOR_BSP` env var read once at startup. +- Mutable at runtime via the DebugPanel checkbox. +- Zero-cost when off — checked before any string formatting. + +Also extends `PhysicsDiagnostics.IndoorAllEnabled` cascading the way Phase 1 cascaded the render-side `ACDREAM_PROBE_INDOOR_ALL`. + +### Component 2 — `[indoor-bsp]` log site + +One `Console.WriteLine` block in `Transition.FindEnvCollisions` ([TransitionTypes.cs:1222](src/AcDream.Core/Physics/TransitionTypes.cs:1222)), wrapping the existing `BSPQuery.FindCollisions` call. Captured fields per call: + +| Field | Source | Why | +|---|---|---| +| `cellId` | `sp.CheckCellId` | Which cell's BSP was queried (hex, full 32-bit) | +| `localPos` | `localCenter` | Sphere foot center in cell-local space (3 floats) | +| `localPrevPos` | `localCurrCenter` | Sphere previous-frame foot center in cell-local space | +| `worldPos` | `footCenter` | Sphere foot center in world space (for cross-ref with user-reported spot) | +| `result` | `cellState` | `TransitionState` enum (`OK` / `Collided` / etc.) | +| `polyId` | `ci.LastHitCellPolyId` (NEW field if needed) | Which cell poly was hit, if any | +| `polyNormal` | `cellPhysics.Resolved[polyId].Plane.Normal` | Local-space normal (3 floats) — diagnoses one-sided / orientation bugs | +| `sidesType` | `cellPhysics.Resolved[polyId].SidesType` | `Front` / `Back` / `Both` — diagnoses #85 candidate | +| `walkable` | `ci.LastKnownContactPlaneValid` | Walkable surface tracking state | + +Log line format (one line, pipe-separated, machine-greppable): + +``` +[indoor-bsp] cell=0xA9B40100 wpos=(82.45,71.23,1.04) lpos=(0.45,2.10,1.02) result=Collided poly=0x0042 n=(0.00,1.00,0.00) sides=Front walkable=true +``` + +If `BSPQuery.FindCollisions` doesn't already expose the hit poly id, the log fields shrink to what's available without expanding the BSPQuery API. A separate small change to surface `lastHitPolyId` from `BSPQuery` would be in-scope for this phase if needed. + +### Component 3 — DebugPanel checkbox + +Adds a checkbox row in the DebugPanel's Diagnostics section (already hosts the L.2a `Resolve` and `Cell-transit` toggles, plus the Phase 1 `Indoor walk/cull/upload/lookup/xform` toggles). Surface area: ~3 lines. No new file. + +### Component 4 — `WorldPicker` cell-BSP occluder + +Two implementation options: + +**Option C1 — Inline in `WorldPicker.Pick`.** Add a `cellOccluder` callback parameter `Func?` that returns the nearest wall-hit `t` along the ray (or `float.PositiveInfinity` if no hit). Inside `Pick`, after computing the entity hit `t`, gate by `entityHit < cellOccluder(origin, direction)`. + +**Option C2 — Separate `CellBspRayOccluder` static class.** New file `src/AcDream.Core/Selection/CellBspRayOccluder.cs`. Function `NearestWallT(Vector3 origin, Vector3 direction, IEnumerable loadedCells)` — Möller-Trumbore ray-triangle against each cell's resolved polys, returns nearest `t`. WorldPicker calls it once per `Pick` invocation. + +**Recommend C2.** Reasons: testable in isolation (synthetic cell + ray); two `WorldPicker.Pick` overloads share one implementation; future picker improvements (entity body refine, scenery BSP refine) get a parallel structure to copy. + +The caller (`GameWindow` Use/Select handlers) must supply the loaded `CellPhysics` set. `PhysicsDataCache` already has `GetCellStruct(id)` so the caller iterates currently-loaded `LoadedCell` ids from `CellVisibility._cellLookup` (Holtburg radius 4 keeps maybe 80 cells loaded — fast Möller-Trumbore). + +### Component 5 — Fix patches (TBD) + +Concrete commits drafted only after capture data lands. Candidates by issue: + +**#84**: +- Remove `+0.02f` Z bump from the physics-side `cellTransform` while keeping it for render's `cellMeshRef` (separate transforms). Or apply the bump symmetrically (also bump player Z by `+0.02f` when entering an indoor cell). +- Filter out physics-only polys with no visible counterpart, IF capture data shows phantom polys are the issue. +- Patch `BSPQuery.FindCollisions` side-type handling, IF capture data shows specific side-types misbehaving. + +**#85**: +- Port retail's outside-in BSP cross-cell probing — query an EnvCell's BSP from an outdoor cell when the sphere overlaps the EnvCell's world AABB. Reference: PDB-named `CCellStructure::find_env_collisions` and neighbors. +- OR ensure outdoor building-shell stab BSPs include wall polys with two-sided handling. +- Path picked from capture evidence + decomp grep. + +--- + +## 5. Data flow + +### Capture session + +User runs the canonical Holtburg launch (`ACDREAM_LIVE=1`, `+Acdream` char) with `ACDREAM_PROBE_INDOOR_BSP=1` + `ACDREAM_PROBE_RESOLVE=1` (latter already shipped from L.2a). Three scripted scenarios: + +1. **Inside Inn walkaround (~30 s)** — walk slowly around the common room, attempt to reproduce #84. Note world-position when an invisible block happens. +2. **Outside-in approach (~30 s)** — stand 5+ m west of the Inn, sprint at the west wall. Reproduce #85. +3. **Inside-out sanity (~30 s)** — stand inside, walk into east wall from interior. This SHOULD block (per issue text); confirms inside-out path works. + +Total launch: one. Captures all three. + +### Offline analysis + +``` +grep "\[indoor-bsp\]" launch.log | head -200 # see what fired during scenario 1 +grep "\[resolve\]" launch.log | grep "obj=0x" # see which objects were hit during scenario 2 +grep "\[cell-transit\]" launch.log # confirm cell ids during transitions +``` + +Diagnosis per issue: + +- **#84**: in scenario-1 lines, find `result=Collided` events where world-pos is in open space (no visible wall). Cross-ref `polyId` with the cell's `cellStruct.PhysicsPolygons` to identify what the offending poly is. Compare its local-Z with player's local-Z to test the Z-bump hypothesis. +- **#85**: in scenario-2 lines, expect zero `[indoor-bsp]` events (gated out). Check `[resolve]` lines for the moment the player crosses the wall plane — did `FindObjCollisions` fire for any building stab? If yes, what poly? If no, the outdoor stab path is missing wall geometry → fix shape is the cross-cell BSP probing. +- **#86**: no capture needed. Code reading already pinned the cause; fix is structural. + +### Fix application + +Per CLAUDE.md "no workarounds" rule: +- The probe data must point at one specific code site before any fix lands. +- Each fix commit cites the evidence in its message ("`[indoor-bsp] cell=0x... wpos=... poly=... n=...` — the poly at local-Z=0.0 is the floor poly; player local-Z=-0.02 from the +0.02f bump puts foot below floor → spurious floor-up push at cell boundary"). +- No try/catch swallow, no early-return guard at the symptom site. + +--- + +## 6. Commit shape + +``` +1. feat(physics): Cluster A — indoor BSP collision probe + - PhysicsDiagnostics.IndoorBspEnabled toggle + env var + DebugPanel checkbox + - [indoor-bsp] log site in TransitionTypes.FindEnvCollisions cell branch + - (if needed) BSPQuery.LastHitPolyId surfacing + + [CAPTURE SESSION — user-driven, no commit] + +2. fix(physics): Cluster A #84 — + - One surgical change to TransitionTypes / GameWindow / BSPQuery + - Commit message cites probe evidence line + - Closes ISSUES.md #84 + +3. fix(physics): Cluster A #85 — + - One surgical change to TransitionTypes or PhysicsDataCache + - Commit message cites probe evidence + retail decomp anchor + - Closes ISSUES.md #85 + +4. fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker + - New CellBspRayOccluder static class (Option C2) + - WorldPicker.Pick (both overloads) consults occluder before returning hit + - Unit test covering synthetic wall-between-camera-and-entity case + - Closes ISSUES.md #86 + +5. docs(roadmap+issues): Cluster A shipped — close #84/#85/#86, update roadmap + - ISSUES.md moves three issues to Recently closed + - docs/plans/2026-04-11-roadmap.md shipped table updated + - CLAUDE.md "Currently in Phase L.2..." line advanced if appropriate +``` + +Visual verification gate sits between commits 4 and 5. User confirms each acceptance criterion in the live client before closing. + +--- + +## 7. Files touched + +**Definite:** + +- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — new `IndoorBspEnabled` toggle. +- `src/AcDream.Core/Physics/TransitionTypes.cs` — `[indoor-bsp]` log site at the cell branch. +- `src/AcDream.App/UI/Panels/DebugPanel.cs` (or wherever the diagnostics checkboxes live) — UI toggle. +- `src/AcDream.Core/Selection/WorldPicker.cs` — call the new occluder. +- `src/AcDream.Core/Selection/CellBspRayOccluder.cs` — new file. +- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LoadedCell` set / `CellPhysics` enumeration into the Use/Select handlers' picker calls. +- `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs` — new unit test for #86 fix. +- `docs/ISSUES.md` — close #84/#85/#86. +- `docs/plans/2026-04-11-roadmap.md` — shipped table entry. + +**TBD (depends on capture):** + +- `src/AcDream.App/Rendering/GameWindow.cs:5362` (+0.02f Z bump site). +- `src/AcDream.Core/Physics/BSPQuery.cs`. +- `src/AcDream.Core/Physics/PhysicsDataCache.cs`. + +--- + +## 8. Error handling + +- Probe always behind `PhysicsDiagnostics.IndoorBspEnabled`. Zero-cost when off. +- Probe writes to `Console.WriteLine`, captured by the launch.log `Tee-Object` pipe (matches existing probe convention). +- `CellBspRayOccluder` returns `float.PositiveInfinity` when no cells are loaded (outdoor camera). Picker behaves exactly as today in that case. +- No try/catch around fix sites. If a fix doesn't behave, the user reports the residual symptom and the probe re-fires to identify the new cause. + +--- + +## 9. Testing + +### Unit tests + +- **`WorldPickerCellOcclusionTests`** (new): synthetic `CellPhysics` with one wall poly between origin and an entity at 5 m. `Pick` returns null. Remove the wall — `Pick` returns the entity. Verifies the occluder is wired and triangulates correctly. +- **`CellBspRayOccluderTests`** (new): direct unit tests for the Möller-Trumbore intersection — ray hits poly front, back, edge, miss, parallel-to-poly. Standard ray-triangle coverage. +- **Existing tests**: `dotnet test` green. `WorldPickerTests` + `WorldPickerRectOverloadTests` + all `BSPQuery` tests must remain green. + +### Visual verification (user-driven) + +Three checks, one per issue: + +1. **#84 acceptance** — User walks the common-room loop in Holtburg Inn. No invisible blocks. Probe shows no `TransitionState != OK` events at positions away from visible walls/furniture. +2. **#85 acceptance** — User stands 5+ m west of the Inn, runs at the west wall. Player blocks at the wall plane (within ~0.05 m of the visible wall surface). User cannot enter the building except via a door portal. +3. **#86 acceptance** — Mouse over a wall pixel from outside the Inn → cursor shows no selection. Mouse over an NPC through an open door portal → cursor shows the NPC selection ring (selection still works through real apertures). + +--- + +## 10. Acceptance criteria + +- All three issues meet their respective acceptance gates above (visual confirmation by user). +- `dotnet build` green. +- `dotnet test` green (new tests + all existing). +- Roadmap "shipped" table updated. +- `docs/ISSUES.md` #84/#85/#86 moved to "Recently closed" with commit SHAs. +- A short post-phase handoff doc (`docs/research/-indoor-walking-phase1-shipped-handoff.md`) records the probe evidence + the three root causes, parallel to the existing Phase 1+2 docs. + +--- + +## 11. Phase name + roadmap placement + +**Proposed name:** "Indoor walking Phase 1 — BSP cluster (#84/#85/#86)". + +Reasons: +- Continues the "Indoor X Phase N" naming established by Phase 1 (probes) + Phase 2 (rendering fix). +- Distinguishes from indoor RENDERING work (which is done) — the focus has shifted to indoor WALKING. +- "Phase 1" implies more phases follow (Phase 2 likely = #78 outdoor-stab visibility cluster). + +**Roadmap placement:** Add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Insert after the Indoor cell rendering Phase 2 entry. Cross-link to ISSUES.md #84/#85/#86. + +**Milestone:** This is parallel to the M2 critical path (which is F.2 / F.3 / F.5a / L.1c / L.1b). M1 already landed and is frozen. Indoor walking work is a quality-of-life parallel track — the user's recent commits put it ahead of M2 work because the rendering Phase 2 ship made it actionable. + +--- + +## 12. Out of scope + +- **#78** — outdoor stabs/buildings visible through rendered floor. Different code path (visibility / stencil). Filed for Indoor walking Phase 2. +- **#79-#82** — lighting / terrain shading. Cluster B in the handoff. Separate phase. +- **#83** — walking up stairs broken. Standalone issue. May share code with this phase if the cell BSP fix touches step-up; address opportunistically only if so. +- **Refactoring `WorldPicker`** beyond adding the occluder. The existing two-overload structure stays. +- **Stage B picker refine** (Möller-Trumbore against entity body polygons) — Issue #71, deferred per existing roadmap. + +--- + +## 13. Risks + +1. **Capture is inconclusive.** If the probe fires zero unexpected events during scenario 1 (i.e., #84 cannot be reproduced live during the capture), we extend the probe to also log `BSPQuery` internals or capture a longer session. Probably one more launch. +2. **#85 fix requires significant retail-decomp port.** Cross-cell BSP probing (querying an EnvCell's BSP from an outdoor cell) is not in the current code. The retail decomp at `named-retail/acclient_2013_pseudo_c.txt` has `CCellStructure::find_env_collisions` and neighbors that handle this. If the port is non-trivial (more than ~100 lines), promote #85 to its own dedicated phase rather than including it here. Decision point: after the capture, before commit 3. +3. **`CellBspRayOccluder` performance.** Möller-Trumbore against ~80 cells × ~50 polys each = ~4K triangle tests per `Pick` call. Picker fires once per click — acceptable. If we ever move to hover-pick (every frame), this needs an acceleration structure; not in scope here. +4. **Probe gets noisy.** If `FindEnvCollisions` fires at 30 Hz × N cells, the log can grow fast. Add a per-call rate limit only if the capture log is unreadable; default to unlimited (Phase 1+2 didn't need limiting). diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 13a660c..302a124 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5381,7 +5381,7 @@ public sealed class GameWindow : IDisposable BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); // Cache CellStruct physics BSP for indoor collision. - _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform); + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform); } } } @@ -5710,6 +5710,49 @@ public sealed class GameWindow : IDisposable } } + // Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit. + // Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world- + // relative origin + orientation) and a Portals list. The landcell id is + // derived from the building's frame origin using retail's row-major grid + // formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock. + if (lbInfo is not null && lbInfo.Buildings.Count > 0) + { + uint lbPrefix = lb.LandblockId & 0xFFFF0000u; + foreach (var building in lbInfo.Buildings) + { + if (building.Portals.Count == 0) continue; + + var bldPortals = new System.Collections.Generic.List( + building.Portals.Count); + foreach (var bp in building.Portals) + { + bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo( + otherCellId: lbPrefix | (uint)bp.OtherCellId, + otherPortalId: bp.OtherPortalId, + flags: (ushort)bp.Flags)); + } + + // Build a world transform for the building. Frame.Origin is + // landblock-relative; add the landblock world origin to get + // world space. + var bldOriginWorld = building.Frame.Origin + origin; + var buildingTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) + * System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld); + + // Derive the outdoor landcell id containing this building. + // Reuse TerrainSurface.ComputeOutdoorCellId rather than + // re-deriving the row-major (gridX * 8 + gridY + 1) formula here. + // Frame.Origin is landblock-relative, same coordinate space as + // ComputeOutdoorCellId expects (local X/Y within the 192m block). + uint landcellLow = terrainSurface.ComputeOutdoorCellId( + building.Frame.Origin.X, building.Frame.Origin.Y); + uint landcellId = lbPrefix | landcellLow; + + _physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform); + } + } + _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, portalPlanes, origin.X, origin.Y); } @@ -9131,6 +9174,17 @@ public sealed class GameWindow : IDisposable var camera = _cameraController.Active; var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y); + // Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently- + // cached EnvCell physics so the picker can occlude entities behind + // walls. Snapshot is per-pick (one click), iteration is bounded + // by the streaming radius (~80 cells at radius 4). + var loadedCellPhysics = new List(); + foreach (var cellId in _physicsDataCache.CellStructIds) + { + 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, @@ -9153,7 +9207,11 @@ public sealed class GameWindow : IDisposable // Match the indicator's TriangleSize (8 px) so the click area // extends out to the bracket corners — what the user perceives // as "selectable extent." - inflatePixels: 8f); + inflatePixels: 8f, + cellOccluder: loadedCellPhysics.Count > 0 + ? (origin, direction) => + AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics) + : null); if (picked is uint guid) { diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 289ff0e..55dc792 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -928,16 +928,24 @@ public static class BSPQuery // ========================================================================= /// - /// BSPNode.point_inside_cell_bsp — test if a 3D point is inside the cell BSP. + /// BSPNode.point_inside_cell_bsp — recursive cell-BSP point containment test. /// /// - /// Follows the front side of each splitting plane. A point is inside when it - /// reaches a front leaf or null PosNode (solid interior). + /// Indoor walking Phase 2 (2026-05-19): retyped from PhysicsBSPNode? to + /// CellBSPNode? — the function operates on the CellBSP tree (which is + /// distinct from the PhysicsBSP tree). The dead-code typing was wrong; + /// no callers existed, so the retype is safe. + /// + /// + /// + /// Walks down the tree following splitting planes; returns true when the + /// point reaches a front leaf or null PosNode (solid interior). Behind + /// any splitting plane → outside. /// /// /// ACE: BSPNode.cs point_inside_cell_bsp. /// - public static bool PointInsideCellBsp(PhysicsBSPNode? node, Vector3 point) + public static bool PointInsideCellBsp(CellBSPNode? node, Vector3 point) { if (node is null) return true; if (node.Type == BSPNodeType.Leaf) return true; @@ -1215,7 +1223,7 @@ public static class BSPQuery { collisions.SetCollisionNormal(collisionNormal); // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly; return TransitionState.Collided; } @@ -1228,14 +1236,14 @@ public static class BSPQuery // the early-out — collisions.SetCollisionNormal isn't called on // this path, but the caller's CollisionInfo.CollisionNormalValid // check will catch the parent slide site's normal write instead. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly; return TransitionState.Collided; } collisions.SetCollisionNormal(collisionNormal); // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly; var adjusted = validPos.Center - checkPos.Center; @@ -1551,7 +1559,7 @@ public static class BSPQuery // is the dominant grounded-player path; without this the // probe's [resolve-bldg] line for every grounded BSP hit was // mis-labeled as "n/a (cylinder)". - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly0; var worldNormal = L2W(hitPoly0!.Plane.Normal); @@ -1585,7 +1593,7 @@ public static class BSPQuery // L.2d slice 1.5 (2026-05-13): same early-record as foot // sphere — head-sphere wall hits also recurse via // StepSphereUp on the grounded path. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly1; var worldNormal = L2W(hitPoly1!.Plane.Normal); @@ -1669,7 +1677,7 @@ public static class BSPQuery collisions.SetCollisionNormal(worldNormal0); collisions.SetSlidingNormal(worldNormal0); // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly0; return TransitionState.Slid; } @@ -1679,7 +1687,7 @@ public static class BSPQuery path.SetCollide(worldNormal0); path.WalkableAllowance = PhysicsGlobals.LandingZ; // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly0; return TransitionState.Adjusted; } @@ -1709,7 +1717,7 @@ public static class BSPQuery collisions.SetCollisionNormal(worldNormal1); collisions.SetSlidingNormal(worldNormal1); // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly1; return TransitionState.Slid; } @@ -1718,7 +1726,7 @@ public static class BSPQuery path.SetCollide(worldNormal1); path.WalkableAllowance = PhysicsGlobals.LandingZ; // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly1; return TransitionState.Adjusted; } diff --git a/src/AcDream.Core/Physics/BuildingPhysics.cs b/src/AcDream.Core/Physics/BuildingPhysics.cs new file mode 100644 index 0000000..f717a58 --- /dev/null +++ b/src/AcDream.Core/Physics/BuildingPhysics.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Physics; + +/// +/// 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 BuildingObj.Portals array +/// (per the pseudocode doc §"LandCell.find_transit_cells"). +/// +public sealed class BuildingPhysics +{ + public required Matrix4x4 WorldTransform { get; init; } + public required Matrix4x4 InverseWorldTransform { get; init; } + public required IReadOnlyList Portals { get; init; } +} + +/// +/// One building portal: the connection from a SortCell's BuildingObj to +/// an interior EnvCell. ExactMatch is decoded from +/// bit 0 (PortalFlags.ExactMatch = 0x0001). +/// +public readonly struct BldPortalInfo +{ + public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags) + { + OtherCellId = otherCellId; + OtherPortalId = otherPortalId; + Flags = flags; + } + + /// Full id of the interior EnvCell this portal connects to. + public uint OtherCellId { get; } + /// The portal id within the destination EnvCell. + public ushort OtherPortalId { get; } + public ushort Flags { get; } + + /// + /// Bit 0 of (DatReaderWriter.Enums.PortalFlags.ExactMatch). + /// + /// + /// Reserved per retail's CBldPortal::exact_match. NOT currently + /// consumed by — every + /// portal overlap is treated as a valid entry trigger. If a future + /// regression surfaces (e.g., a building entered by overlapping a + /// non-exact-match portal), wire this into the entry test. + /// + /// + public bool ExactMatch => (Flags & (ushort)PortalFlags.ExactMatch) != 0; +} diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs new file mode 100644 index 0000000..564a46f --- /dev/null +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -0,0 +1,326 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, +/// ported from retail's CObjCell::find_cell_list family +/// (sphere variant for the player's single foot sphere). +/// +/// +/// Replaces Phase D's AABB containment. Uses the cell BSP for retail- +/// faithful point-in-cell tests via +/// . Walks the portal graph +/// starting from a given current cell to find which cells a moving +/// sphere overlaps. +/// +/// +/// +/// Reference pseudocode: +/// docs/research/acclient_indoor_transitions_pseudocode.md +/// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells +/// (sphere variant) at acclient_2013_pseudo_c.txt. +/// +/// +public static class CellTransit +{ + /// + /// Small radius padding matching retail's EPSILON usage in the + /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells"). + /// + private const float EPSILON = 0.02f; + + /// + /// Indoor portal-neighbour expansion. For each portal of + /// , test whether the sphere overlaps + /// the portal polygon's plane in cell-local space. If so, add the + /// neighbour cell to . + /// + /// + /// Ported from CEnvCell::find_transit_cells (sphere variant) + /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)". + /// + /// + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet 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; + continue; + } + + uint otherId = lbPrefix | portal.OtherCellId; + + // Conservative add: the sphere is near the portal plane and on the + // outward side (per PortalSide). This is the load-hint branch from + // the research doc. A more retail-faithful path would call + // CellBSP.sphere_intersects_cell on the neighbour — deferred. + if (portal.PortalSide ? dist > -rad : dist < rad) + candidates.Add(otherId); + } + } + + /// + /// Outdoor neighbour expansion. Ported from + /// CLandCell::add_all_outside_cells (sphere variant) per the + /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)". + /// + /// + /// 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. + /// + /// + public static void AddAllOutsideCells( + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + HashSet candidates) + { + const float CellSize = 24f; + + uint lbPrefix = currentCellId & 0xFFFF0000u; + + float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f; + float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f; + float localX = worldSphereCenter.X - lbXf; + float localY = worldSphereCenter.Y - lbYf; + + float cellLocalX = localX % CellSize; + float cellLocalY = localY % CellSize; + float minRad = sphereRadius; + float maxRad = CellSize - sphereRadius; + + 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); + + 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 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. + uint low = (uint)(gridX * 8 + gridY + 1); + candidates.Add(lbPrefix | low); + } + + /// + /// Outdoor→indoor entry path. Ported from retail's + /// BuildingObj::find_building_transit_cells + + /// EnvCell::check_building_transit. For each portal of the + /// outdoor building, look up the destination interior cell and test + /// whether the sphere center is inside it via + /// . If so, add the interior + /// cell to . + /// + /// + /// Retail divergence: retail's check_building_transit + /// uses CCellStruct::sphere_intersects_cell (radius-aware + /// BSP-vs-sphere test) which fires the moment ANY part of the sphere + /// overlaps the destination cell. Our port uses + /// (radius-less, tests only + /// the sphere CENTER). Practical effect: entry into a building fires + /// when the player's foot-sphere center crosses the destination cell + /// boundary — roughly (~0.48m) DEEPER + /// into the doorway than retail. If visual verification at the cottage + /// door shows a noticeable "late entry" effect (player visually inside + /// the building before walls switch from outdoor-stab to indoor-cell), + /// port sphere_intersects_cell in a follow-up. + /// is plumbed through for that future + /// upgrade; currently unused. + /// + /// + public static void CheckBuildingTransit( + PhysicsDataCache cache, + BuildingPhysics building, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet candidates) + { + foreach (var portal in building.Portals) + { + var otherCell = cache.GetCellStruct(portal.OtherCellId); + if (otherCell?.CellBSP?.Root is null) + { + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + string reason = otherCell is null ? "cell not cached" : "CellBSP null"; + Console.WriteLine(System.FormattableString.Invariant( + $"[check-bldg] portal->0x{portal.OtherCellId:X8} skipped: {reason}")); + } + continue; + } + + // Sphere center in the OTHER cell's local space. + var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform); + bool inside = BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter); + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[check-bldg] portal->0x{portal.OtherCellId:X8} wpos=({worldSphereCenter.X:F3},{worldSphereCenter.Y:F3},{worldSphereCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) inside={inside}")); + } + + if (inside) + { + candidates.Add(portal.OtherCellId); + } + } + } + + /// + /// Top-level cell-tracking driver, ported from retail's + /// CObjCell::find_cell_list (sphere variant). + /// + /// + /// Walks the portal graph from , + /// finds the cell whose contains + /// the sphere center, and returns its full id (landblock-prefixed). + /// Falls back to when no candidate + /// matches. + /// + /// + /// + /// Pseudocode reference: + /// docs/research/acclient_indoor_transitions_pseudocode.md + /// §"Overall Driver: find_cell_list". + /// + /// + public static uint FindCellList( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId) + { + var candidates = new HashSet(); + 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(); + var visited = new HashSet(); + pending.Enqueue(currentCellId); + visited.Add(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); + + if (candidates.Count > sizeBefore) + { + foreach (var c in candidates) + { + if (visited.Add(c)) // only enqueue if NEW + pending.Enqueue(c); + } + } + + if (exitOutside) + { + // Add neighbour outdoor cells too. + AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + } + } + } + else + { + // Outdoor seed: expand neighbour landcells AND check for building stabs + // with portals into interior EnvCells. + AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + + // For each landcell candidate, see if it carries a building stab; if so, + // check whether the sphere has crossed into any of the building's interior + // EnvCells via CheckBuildingTransit. + // + // NOTE: PhysicsEngine.ResolveCellId currently bypasses this entire branch + // for outdoor seeds (it uses its own _landblocks terrain grid loop). The + // outdoor→indoor production path therefore runs through ResolveCellId's + // OWN outdoor branch (see below for the call there too). This block is + // exercised by direct-FindCellList callers (tests, future re-entry from + // an indoor cell exiting through a portal that lands outside near a + // building). + var landcellSnapshot = new List(candidates); + foreach (uint landcellId in landcellSnapshot) + { + var building = cache.GetBuilding(landcellId); + if (building is null) continue; + CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates); + } + } + + // 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; + } +} diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 771f208..ee58a7c 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -20,6 +20,9 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _setup = new(); private readonly ConcurrentDictionary _cellStruct = new(); + // ── Phase 2: building portal cache for outdoor→indoor entry ─────────── + private readonly ConcurrentDictionary _buildings = new(); + /// /// Extract and cache the physics BSP + polygon data from a GfxObj, /// PLUS always cache a visual AABB from the vertex data regardless of @@ -128,14 +131,39 @@ public sealed class PhysicsDataCache /// (indoor room geometry). No-ops if the id is already cached or the /// CellStruct has no physics BSP. /// - public void CacheCellStruct(uint envCellId, CellStruct cellStruct, - Matrix4x4 worldTransform) + 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(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. + // envCell.VisibleCells is List per the DatReaderWriter shape — iterate directly, no .Keys. + var visibleCellIds = new System.Collections.Generic.HashSet(); + if (envCell.VisibleCells is not null) + { + uint lbPrefix = envCellId & 0xFFFF0000u; + foreach (var lowId in envCell.VisibleCells) + visibleCellIds.Add(lbPrefix | lowId); + } + _cellStruct[envCellId] = new CellPhysics { BSP = cellStruct.PhysicsBSP, @@ -143,8 +171,53 @@ public sealed class PhysicsDataCache Vertices = cellStruct.VertexArray, WorldTransform = worldTransform, InverseWorldTransform = inverseTransform, - Resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray), + Resolved = resolved, + // ── Phase 2 portal fields ── + CellBSP = cellStruct.CellBSP, + 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; + + int bspTotalLeafPolys = 0; + int bspUnmatchedIds = 0; + if (root is not null) + { + var stack = new System.Collections.Generic.Stack(); + 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); + + 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?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})")); + } } /// @@ -152,7 +225,7 @@ public sealed class PhysicsDataCache /// and compute the face plane. Matches ACE's Polygon constructor which calls /// make_plane() and resolves Vertices from VertexIDs at load time. /// - private static Dictionary ResolvePolygons( + internal static Dictionary ResolvePolygons( Dictionary polys, VertexArray vertexArray) { @@ -210,6 +283,15 @@ public sealed class PhysicsDataCache public int SetupCount => _setup.Count; public int CellStructCount => _cellStruct.Count; + /// + /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached + /// EnvCell ids — used by + /// to enumerate occluder candidates without exposing the underlying + /// dictionary. Returns the live key-set; callers should snapshot the + /// collection if they need stability across frames. + /// + public IReadOnlyCollection CellStructIds => (IReadOnlyCollection)_cellStruct.Keys; + /// /// Register a pre-built directly. /// Intended for unit-test fixtures that construct synthetic BSP trees @@ -217,6 +299,39 @@ public sealed class PhysicsDataCache /// public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics) => _gfxObj[gfxObjId] = physics; + + /// + /// Register a pre-built directly. Intended for + /// unit-test fixtures that construct synthetic cells without going through + /// dat-driven . + /// + public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) + => _cellStruct[envCellId] = physics; + + /// + /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list + /// for an outdoor landcell that contains a building stab. Used by + /// . + /// + public void CacheBuilding(uint landcellId, IReadOnlyList 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 BuildingIds => (IReadOnlyCollection)_buildings.Keys; + + /// Test helper, mirrors . + public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; } /// @@ -285,9 +400,15 @@ public sealed class SetupPhysics /// public sealed class CellPhysics { - public required PhysicsBSPTree BSP { get; init; } - public required Dictionary PhysicsPolygons { get; init; } - public required VertexArray Vertices { get; init; } + /// + /// The physics BSP tree for this cell. Nullable so that test fixtures + /// can construct a from + /// alone without needing a real DAT BSP object. Production code must + /// null-check before traversal: cell.BSP?.Root is not null. + /// + public PhysicsBSPTree? BSP { get; init; } + public Dictionary? PhysicsPolygons { get; init; } + public VertexArray? Vertices { get; init; } public Matrix4x4 WorldTransform { get; init; } public Matrix4x4 InverseWorldTransform { get; init; } @@ -295,4 +416,39 @@ public sealed class CellPhysics /// Pre-resolved polygon data with vertex positions and computed planes. /// public required Dictionary Resolved { get; init; } + + // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ─────── + + /// + /// The cell BSP used for + /// (point-in-cell tests). Separate tree from + /// (collision) and from the renderer's drawing-BSP. + /// Source: cellStruct.CellBSP at cache time. + /// Nullable: cells without a CellBSP cannot participate in portal + /// containment and are skipped by . + /// + public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } + + /// + /// Portal connections to neighbouring cells, in cell-local space. + /// Default: empty list. Source: envCell.CellPortals. + /// + public IReadOnlyList Portals { get; init; } = System.Array.Empty(); + + /// + /// Resolved VISIBLE polygons (from cellStruct.Polygons), + /// keyed by polygon id. Distinct from which + /// holds PhysicsPolygons. Portal lookup via + /// resolves through this dict. + /// Nullable when the cell has no visible polys (rare). + /// + public Dictionary? PortalPolygons { get; init; } + + /// + /// The full cell ids visible from this cell (with landblock prefix). + /// Populated from envCell.VisibleCells at cache time. Unused + /// this phase; reserved for the optional find_cell_list + /// visibility filter. + /// + public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); } diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index a8649a0..05935e8 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -166,4 +166,59 @@ public static class PhysicsDiagnostics /// public static bool DumpSteepRoofEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"; + + /// + /// Indoor walking Phase 1 (2026-05-19). When true, emits one + /// [indoor-bsp] line per + /// call made from 's indoor + /// cell-BSP branch. Captures the cell id, sphere local position, + /// resulting , 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). + /// + /// + /// While true, this also un-gates the diagnostic + /// side-channel inside + /// — see the OR'd condition at every poly + /// write site. Zero-cost when off. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_INDOOR_BSP=1. + /// Runtime-toggleable via DebugPanel. + /// + /// + /// + /// Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md. + /// + /// + public static bool ProbeIndoorBspEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1"; + + /// + /// Indoor walking Phase D follow-up (2026-05-19). When true, emits one + /// [cell-cache] line each time + /// caches a new EnvCell. Reports per-cell polygon counts and BSP root + /// structure so the caller can cross-reference with [indoor-bsp] + /// lines to distinguish between: + /// + /// Empty data (physicsPolyCount=0 or resolvedCount=0) + /// — candidate (a)/(c) in the poly=n/a investigation. + /// Non-zero polygon counts but bspRootPolyCount=0 at + /// root + tree has children — correct structure for non-leaf root, + /// leaves hold the poly refs; not a bug. + /// Non-zero polygon counts but bspRootPolyCount=0 at + /// root AND root is a leaf (bspRootHasChildren=false) — BSP leaf with + /// zero poly refs, candidate (b)/(d). + /// + /// This diagnostic fires at most once per EnvCell (cache is no-op after + /// first population). It does NOT have a DebugPanel mirror yet — this is + /// a one-shot capture tool, not a persistent toggle. Promote to full + /// infrastructure after the root cause is identified. + /// + /// Initial state from ACDREAM_PROBE_CELL_CACHE=1. + /// + public static bool ProbeCellCacheEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1"; } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index a5bdf92..5061f34 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -230,20 +230,43 @@ public sealed class PhysicsEngine } /// - /// Resolve the outdoor cell id that owns a world-space position. - /// Indoor ids are preserved because EnvCell ownership still comes from - /// portal/cell BSP state; outdoor ids are derived from the registered - /// landblock that currently contains the point. + /// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a + /// given world position via retail's portal-graph traversal for indoor + /// cells, or via terrain grid lookup for outdoor cells. + /// + /// + /// Indoor seed: delegates to which + /// BFS-walks the portal graph and uses + /// for containment. This replaces Phase D's AABB shortcut. + /// + /// + /// + /// Outdoor seed: uses the registered landblock terrain grid to compute + /// the correct prefixed cell ID, preserving the pre-existing outdoor + /// resolution behavior (the L.2e prefix-preservation fix). + /// + /// + /// + /// Design: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md + /// /// - internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId) + internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId) { - if (fallbackCellId == 0) - return 0; + if (fallbackCellId == 0) return 0; uint fallbackLow = fallbackCellId & 0xFFFFu; - if (fallbackLow >= 0x0100u) - return fallbackCellId; + if (fallbackLow >= 0x0100u) + { + // Indoor branch needs DataCache to look up cells; outdoor uses + // _landblocks (no DataCache dependency). + if (DataCache is null) return fallbackCellId; + return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId); + } + + // Outdoor seed: use terrain grid to compute the prefixed cell id. + // Preserves the L.2e prefix-preservation fix (always apply the matched + // landblock's high-16 prefix even when fallbackCellId arrived bare-low-byte). foreach (var kvp in _landblocks) { var lb = kvp.Value; @@ -252,9 +275,29 @@ public sealed class PhysicsEngine if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) { uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); - return (fallbackCellId & 0xFFFF0000u) == 0 - ? lowCellId - : (kvp.Key & 0xFFFF0000u) | lowCellId; + uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId; + + // Outdoor→indoor entry: if this landcell has a cached building, + // check whether the sphere has crossed into one of its interior + // EnvCells via the building's portals. + if (DataCache is not null) + { + var building = DataCache.GetBuilding(outdoorCellId); + if (building is not null) + { + var candidates = new System.Collections.Generic.HashSet(); + CellTransit.CheckBuildingTransit( + DataCache, building, worldPos, sphereRadius, candidates); + if (candidates.Count > 0) + { + // First candidate wins — building portal containment is + // mutually exclusive in retail (one interior cell per portal). + foreach (var c in candidates) return c; + } + } + } + + return outdoorCellId; } } @@ -726,7 +769,7 @@ public sealed class PhysicsEngine return new ResolveResult( sp.CheckPos, - ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId), + ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), onGround, collisionNormalValid, collisionNormal); @@ -744,7 +787,7 @@ public sealed class PhysicsEngine uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; return new ResolveResult( sp.CheckPos, - ResolveOutdoorCellId(sp.CheckPos, partialCellId), + ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), partialOnGround, collisionNormalValid, collisionNormal); diff --git a/src/AcDream.Core/Physics/PortalInfo.cs b/src/AcDream.Core/Physics/PortalInfo.cs new file mode 100644 index 0000000..8b117d0 --- /dev/null +++ b/src/AcDream.Core/Physics/PortalInfo.cs @@ -0,0 +1,45 @@ +namespace AcDream.Core.Physics; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal connection between two +/// EnvCells. Each carries a list of these, +/// mirroring retail's CCellStruct.portals array. +/// +/// +/// is a low-16 cell index (combined with the +/// owning landblock prefix at lookup time) or 0xFFFF to mean +/// "exit to outdoor world" (the player crosses this portal to leave +/// the building). +/// +/// +/// +/// indexes the OWNING cell's +/// dict (the visible-polygon +/// table, NOT which holds physics +/// polys). +/// +/// +/// +/// decodes bit 2 of : +/// (Flags & 2) == 0 → 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 find_transit_cells's +/// load-hint path for unloaded neighbours. +/// +/// +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; } + + /// Bit 2 of . See struct docstring. + public bool PortalSide => (Flags & 2) == 0; +} diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 9fa7ba2..4a3b8e8 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1166,6 +1166,92 @@ public sealed class Transition // Environment collision — outdoor terrain // ----------------------------------------------------------------------- + /// + /// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor + /// polygon directly under within + /// . Used when the indoor cell-BSP query + /// returns OK (no wall collision) — we need to provide a walkable contact + /// plane from the cell's geometry instead of falling through to outdoor + /// terrain (which is below the cell floor due to the +0.02f Z-bump + /// applied at GameWindow.BuildInteriorEntitiesForStreaming). + /// + /// + /// Iterates physics polygons; selects + /// the one with the most upward-facing normal (Z >= 0.6664 = walkable + /// slope threshold matching retail's WalkableSlopeMin) whose XY projection + /// contains the player's local foot XY. Returns the polygon's plane + + /// vertices in WORLD space for the ValidateWalkable call. + /// + /// + /// + /// Returns false if no walkable floor poly is found under the + /// player. The caller falls through to outdoor terrain in that case + /// (defensive backstop — should not normally happen inside a sealed cell). + /// + /// + internal static bool TryFindIndoorWalkablePlane( + CellPhysics cellPhysics, + Vector3 localFootCenter, + out System.Numerics.Plane worldPlane, + out Vector3[] worldVertices, + out uint hitPolyId) + { + worldPlane = default; + worldVertices = System.Array.Empty(); + hitPolyId = 0; + + foreach (var (id, poly) in cellPhysics.Resolved) + { + // Walkable slope threshold matches retail WalkableSlopeMin (0.6664...) + // and our existing TerrainSurface.WalkableSlopeMin check. + if (poly.Plane.Normal.Z < 0.6664f) continue; + if (poly.Vertices is null || poly.Vertices.Length < 3) continue; + + // Point-in-polygon test in XY (ignore Z). Ray-casting even-odd rule. + if (!PointInPolygonXY(localFootCenter, poly.Vertices)) continue; + + // Found a floor poly under the player. Transform plane + vertices + // to world space. + var worldNormal = Vector3.TransformNormal(poly.Plane.Normal, cellPhysics.WorldTransform); + worldNormal = Vector3.Normalize(worldNormal); + // Take vertex 0, transform to world, recompute D so the plane + // equation normal·p + D = 0 holds at the world-space vertex. + var worldV0 = Vector3.Transform(poly.Vertices[0], cellPhysics.WorldTransform); + float worldD = -Vector3.Dot(worldNormal, worldV0); + worldPlane = new System.Numerics.Plane(worldNormal, worldD); + + worldVertices = new Vector3[poly.Vertices.Length]; + for (int i = 0; i < poly.Vertices.Length; i++) + worldVertices[i] = Vector3.Transform(poly.Vertices[i], cellPhysics.WorldTransform); + + hitPolyId = id; + return true; + } + + return false; + } + + /// + /// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting + /// even-odd rule. Works for convex and concave polygons. + /// + internal static bool PointInPolygonXY(Vector3 point, Vector3[] vertices) + { + bool inside = false; + int n = vertices.Length; + for (int i = 0, j = n - 1; i < n; j = i++) + { + var vi = vertices[i]; + var vj = vertices[j]; + if (((vi.Y > point.Y) != (vj.Y > point.Y)) && + (point.X < (vj.X - vi.X) * (point.Y - vi.Y) / (vj.Y - vi.Y) + vi.X)) + { + inside = !inside; + } + } + return inside; + } + /// /// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic. /// Indoor BSP collision is deferred to Task 6c. @@ -1178,13 +1264,13 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId); - if (resolvedOutdoorCellId != sp.CheckCellId) - sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); - Vector3 footCenter = sp.GlobalSphere[0].Origin; float sphereRadius = sp.GlobalSphere[0].Radius; + uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId); + if (resolvedOutdoorCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); + // ── Indoor cell BSP collision ──────────────────────────────────── // If the player is in an indoor cell (low 16 bits >= 0x0100), // query the CellStruct's PhysicsBSP for wall/floor/ceiling collision. @@ -1217,6 +1303,12 @@ public sealed class Transition }; } + // 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( @@ -1231,12 +1323,57 @@ public sealed class Transition 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( + $"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; } + + // ── Synthesize indoor walkable contact plane ────────────── + // Indoor walking Phase 2 follow-up (2026-05-19). When the BSP + // returns OK (no wall collision), the player is standing on a + // floor poly inside the cell. We must NOT fall through to + // outdoor terrain (SampleTerrainWalkable) — the outdoor terrain + // Z is below the indoor floor due to the +0.02f Z-bump applied + // for render z-fight prevention. ValidateWalkable would then see + // the player 0.5m above the outdoor plane → marks them as + // airborne → walkable=False → falling animation, never recovers. + // + // Retail: CEnvCell::find_env_collisions returns from the cell + // branch with the cell's walkable plane set — no fall-through + // to terrain. + if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, + out var indoorPlane, + out var indoorVertices, + out uint _)) + { + return ValidateWalkable( + footCenter, + sphereRadius, + indoorPlane, + isWater: false, + waterDepth: 0f, + cellId: sp.CheckCellId, + walkableVertices: indoorVertices); + } + // If no walkable floor was found under the player indoors + // (rare — cell with only walls/ceiling), fall through to + // outdoor terrain as a defensive backstop. Indoor walking + // will report walkable=False until the player moves over a + // cell with a proper floor poly. } } diff --git a/src/AcDream.Core/Selection/CellBspRayOccluder.cs b/src/AcDream.Core/Selection/CellBspRayOccluder.cs new file mode 100644 index 0000000..49d5283 --- /dev/null +++ b/src/AcDream.Core/Selection/CellBspRayOccluder.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Selection; + +/// +/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon +/// occlusion test. Given a ray and a set of +/// (currently-loaded EnvCells with resolved polygon planes), returns +/// the nearest world-space t along the ray that hits any cell +/// polygon — or if the ray clears +/// all cells. +/// +/// +/// Used by 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 +/// so the ray runs in cell-local space and the resolved-polygon +/// vertices don't need re-transformation per query. +/// +/// +/// +/// 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 Pick call is well under 1 ms. +/// +/// +public static class CellBspRayOccluder +{ + /// + /// Returns the nearest positive t such that + /// origin + t * direction intersects a polygon in any cell. + /// Returns if no cell polygon + /// is intersected. + /// + /// Need not be normalized; returned t + /// scales with direction length the same as a parametric ray. + public static float NearestWallT( + Vector3 origin, + Vector3 direction, + IEnumerable 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; + } + + /// + /// Möller-Trumbore ray-triangle intersection. Returns true with + /// t in if the ray hits the triangle + /// at a positive distance. + /// + 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; + } +} diff --git a/src/AcDream.Core/Selection/WorldPicker.cs b/src/AcDream.Core/Selection/WorldPicker.cs index f95a93f..2b6fc67 100644 --- a/src/AcDream.Core/Selection/WorldPicker.cs +++ b/src/AcDream.Core/Selection/WorldPicker.cs @@ -91,13 +91,20 @@ public static class WorldPicker uint skipServerGuid, float maxDistance = 50f, Func? radiusForGuid = null, - Func? verticalOffsetForGuid = null) + Func? verticalOffsetForGuid = null, + Func? cellOccluder = null) { const float DefaultRadius = 1.0f; const float DefaultVerticalOffset = 0.9f; if (direction.LengthSquared() < 1e-10f) return null; + // Indoor walking Phase 1 #86 (2026-05-19): if the caller provides + // a cell-BSP occluder, query the nearest wall hit along the ray + // ONCE; entities whose ray-t exceeds the wall-t sit behind a wall + // and are skipped. + float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity; + uint? bestGuid = null; float bestT = float.PositiveInfinity; foreach (var entity in candidates) @@ -150,6 +157,7 @@ public static class WorldPicker if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit if (t < 0f) continue; // both roots negative -> sphere entirely behind ray if (t >= maxDistance) continue; + if (t >= wallT) continue; // wall is between camera and entity (#86) if (t < bestT) { bestT = t; @@ -207,11 +215,39 @@ public static class WorldPicker IEnumerable candidates, uint skipServerGuid, Func sphereForEntity, - float inflatePixels = 8f) + float inflatePixels = 8f, + Func? cellOccluder = null) { uint? bestGuid = null; float bestDepth = float.PositiveInfinity; + // Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion. + // Build the click ray, query the nearest wall along it, convert + // to the same camera-space depth metric (clip.W) that + // ScreenProjection.TryProjectSphereToScreenRect returns per + // candidate. Candidates with depth > wallDepth sit behind a wall. + float wallDepth = float.PositiveInfinity; + if (cellOccluder is not null) + { + var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection); + if (rayDir.LengthSquared() > 0f) + { + float wallT = cellOccluder(rayOrigin, rayDir); + if (!float.IsPositiveInfinity(wallT)) + { + var wallPoint = rayOrigin + rayDir * wallT; + // ScreenProjection uses clip.W as its depth metric — + // "camera-space depth" in the row-vector convention is + // the W component of the homogeneous clip-space vector, + // which equals the eye-space Z distance to the point. + var viewProj = view * projection; + var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj); + if (clip.W > 0f) + wallDepth = clip.W; + } + } + } + foreach (var entity in candidates) { if (entity.ServerGuid == 0u) continue; @@ -237,6 +273,8 @@ public static class WorldPicker if (mouseX < minX || mouseX > maxX) continue; if (mouseY < minY || mouseY > maxY) continue; + if (depth > wallDepth) continue; // wall is between camera and entity (#86) + if (depth < bestDepth) { bestDepth = depth; diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs index bcf58be..6593f90 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs @@ -275,6 +275,9 @@ public sealed class DebugPanel : IPanel if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform; if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull; + bool probeIndoorBsp = _vm.ProbeIndoorBsp; + if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp; + r.Spacing(); // Cycle / toggle actions live on the VM as Action handles; the diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs index b051dc0..731ee9e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -345,6 +345,20 @@ public sealed class DebugVM set => RenderingDiagnostics.ProbeIndoorCullEnabled = value; } + /// + /// Indoor walking Phase 1 (2026-05-19). Runtime mirror of + /// PhysicsDiagnostics.ProbeIndoorBspEnabled (env var + /// ACDREAM_PROBE_INDOOR_BSP). Toggling here flips the + /// [indoor-bsp] probe live — no relaunch required. + /// Physics-side companion to the five render-side + /// ProbeIndoor* mirrors directly above. + /// + public bool ProbeIndoorBsp + { + get => PhysicsDiagnostics.ProbeIndoorBspEnabled; + set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value; + } + /// /// Runtime mirror of RenderingDiagnostics.IndoorAll — toggles all /// five indoor probes together. No dedicated env var; set any individual diff --git a/tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs b/tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs new file mode 100644 index 0000000..00e7693 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellPhysicsPortalWiringTests +{ + [Fact] + public void NewFields_HaveSensibleDefaults() + { + var cp = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new System.Collections.Generic.Dictionary(), + }; + + 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(), + Portals = new[] { portal }, + VisibleCellIds = new System.Collections.Generic.HashSet { 0xA9B40101 }, + }; + + Assert.Single(cp.Portals); + Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId); + Assert.Contains(0xA9B40101u, cp.VisibleCellIds); + } + + [Fact] + public void CellPhysics_PortalsRoundTrip() + { + 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(), + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs new file mode 100644 index 0000000..5ef0b74 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitAddAllOutsideCellsTests +{ + [Fact] + public void SphereWellInsideCell_AddsOneCell() + { + // Player at world (12, 12, 0) in landblock 0xA9B40000 → cell (0,0). + // Landblock origin: 0xA9 = 169 → world X = 169*192 = 32448. + // 0xB4 = 180 → world Y = 180*192 = 34560. + // Player needs to be in cell (0,0) RELATIVE to landblock origin: + // world X = 32448 + 12 = 32460 + // world Y = 34560 + 12 = 34572 + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(32460f, 34572f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40001u, + candidates); + + Assert.Single(candidates); + Assert.Contains(0xA9B40001u, candidates); + } + + [Fact] + public void SphereAtCellEastBoundary_AddsTwoCells() + { + // Player at world (32448 + 23.6, 34560 + 12, 0) — near +X edge of cell (0,0). + // Sphere reach to localX = 23.6 + 0.5 = 24.1 → cell (1,0) added. + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(32448f + 23.6f, 34560f + 12f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40001u, + candidates); + + Assert.Equal(2, candidates.Count); + Assert.Contains(0xA9B40001u, candidates); + // Cell (1,0): low-16 id = 1 * 8 + 0 + 1 = 9 → 0x0009. + Assert.Contains(0xA9B40009u, candidates); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs new file mode 100644 index 0000000..6ea51dc --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitCheckBuildingTransitTests +{ + [Fact] + public void BuildingPortalWithUnloadedCellBSP_NoCandidateAdded() + { + // Verifies the null-CellBSP guard: when the destination interior cell + // is cached but its CellBSP isn't yet loaded (or is structurally absent), + // CheckBuildingTransit must NOT add the cell to candidates — even though + // PointInsideCellBsp(null, _) returns true. + // + // Happy-path (CellBSP present, sphere inside) requires a synthetic + // CellBSPTree which is non-trivial to construct from DatReaderWriter + // types. Deferred to visual verification. + + // 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), + }, + }; + + // Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true, + // but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this + // cell is skipped. + var interiorCell = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, interiorCell); + + var candidates = new HashSet(); + CellTransit.CheckBuildingTransit( + cache, building, + worldSphereCenter: new Vector3(0, 0, 0), + sphereRadius: 0.5f, + candidates); + + // CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null) + // skips this cell. No candidate added. + Assert.Empty(candidates); + } + + // A second test that uses a synthetic CellBSP whose Root.Type == BSPNodeType.Leaf + // (which PointInsideCellBsp short-circuits as "inside") would verify the + // happy path. Constructing a CellBSPTree by hand from DatReaderWriter + // types is awkward; deferred to integration testing at visual-verify time. +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs new file mode 100644 index 0000000..823658d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs @@ -0,0 +1,38 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitFindCellListTests +{ + [Fact] + public void IndoorSeed_NoCacheEntry_ReturnsFallback() + { + var cache = new PhysicsDataCache(); + // Indoor seed but cell not cached → FindCellList early-returns the fallback. + uint result = CellTransit.FindCellList( + cache, + worldSphereCenter: Vector3.Zero, + sphereRadius: 0.5f, + currentCellId: 0xA9B40100u); + + Assert.Equal(0xA9B40100u, result); + } + + [Fact] + public void OutdoorSeed_Returns_FallbackWhenNoCellBSPs() + { + var cache = new PhysicsDataCache(); + // Outdoor seed: AddAllOutsideCells adds landcell candidates, but they + // have no CellPhysics (only EnvCells get cached) → containment loop + // finds no winner → fall back. + uint result = CellTransit.FindCellList( + cache, + worldSphereCenter: new Vector3(12f, 12f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40001u); + + Assert.Equal(0xA9B40001u, result); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs new file mode 100644 index 0000000..cc9db97 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitFindTransitCellsSphereTests +{ + private static CellPhysics MakeCellWithPortalAtRightWall( + Matrix4x4 worldTransform, uint otherCellId, ushort flags) + { + // 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, + }; + + Matrix4x4.Invert(worldTransform, out var inv); + return new CellPhysics + { + WorldTransform = worldTransform, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + PortalPolygons = new Dictionary { [10] = portalPolyA }, + Portals = new[] + { + new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags), + }, + }; + } + + [Fact] + public void SphereInsideCellA_NearPortal_AddsCellB() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + + var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); + Matrix4x4.Invert(cellBT, out var cellBInv); + var cellB = new CellPhysics + { + WorldTransform = cellBT, + InverseWorldTransform = cellBInv, + Resolved = new Dictionary(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + cache.RegisterCellStructForTest(0xA9B40101u, cellB); + + // Sphere center near portal (local x=2.0, radius=0.5 → reaches x=2.5 = portal plane). + var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f); + + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellA, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.Contains(0xA9B40101u, candidates); + Assert.False(exitOutside); + } + + [Fact] + public void SphereInsideCellA_FarFromPortal_DoesNotAddCellB() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + + // Sphere far from portal (local x=-1.0, reach to x=-0.5 — nowhere near portal at x=2.5). + var worldSphereCenter = new Vector3(-1.0f, 0f, 2.5f); + + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellA, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.DoesNotContain(0xA9B40101u, candidates); + } + + [Fact] + public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside() + { + var exitCell = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0); + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, exitCell); + + var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f); + var candidates = new HashSet(); + + CellTransit.FindTransitCellsSphere( + cache, exitCell, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.True(exitOutside); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs new file mode 100644 index 0000000..1a455c6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for and +/// . +/// +/// Indoor walking Phase 2 follow-up (2026-05-19): these helpers synthesize +/// a walkable contact plane from cell floor polys so the resolver does not +/// fall through to outdoor terrain when the player is standing indoors. +/// +public class IndoorWalkablePlaneTests +{ + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// Builds a CellPhysics with a single upward-facing floor polygon + /// (a 10×10 square in the XY plane at local Z=0), plus identity transforms. + /// + private static CellPhysics BuildCellWithFloor(float floorZ = 0f) + { + var verts = new[] + { + new Vector3(-5f, -5f, floorZ), + new Vector3( 5f, -5f, floorZ), + new Vector3( 5f, 5f, floorZ), + new Vector3(-5f, 5f, floorZ), + }; + var normal = new Vector3(0f, 0f, 1f); // straight up + float D = -Vector3.Dot(normal, verts[0]); // = -floorZ + + var floorPoly = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, D), + NumPoints = 4, + SidesType = CullMode.None, + }; + + return new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [0] = floorPoly }, + }; + } + + // ----------------------------------------------------------------------- + // TryFindIndoorWalkablePlane + // ----------------------------------------------------------------------- + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue() + { + var cell = BuildCellWithFloor(floorZ: 0f); + var localFoot = new Vector3(0f, 0f, 0.5f); // centred over the 10×10 square + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, localFoot, + out var plane, out var verts, out uint polyId); + + Assert.True(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp() + { + var cell = BuildCellWithFloor(floorZ: 0f); + var localFoot = new Vector3(0f, 0f, 0.5f); + + Transition.TryFindIndoorWalkablePlane( + cell, localFoot, out var plane, out _, out _); + + // The floor's normal must point up (Z close to 1). + Assert.True(plane.Normal.Z > 0.99f, + $"Expected plane.Normal.Z > 0.99, got {plane.Normal.Z}"); + } + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneAtFloorZ() + { + const float floorZ = 2.5f; + var cell = BuildCellWithFloor(floorZ); + var localFoot = new Vector3(0f, 0f, floorZ + 0.5f); + + Transition.TryFindIndoorWalkablePlane( + cell, localFoot, out var plane, out _, out _); + + // With identity transform and an upward normal, plane.D = -floorZ. + // The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1). + Assert.True(MathF.Abs(plane.D - (-floorZ)) < 1e-4f, + $"Expected plane.D ≈ {-floorZ}, got {plane.D}"); + } + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse() + { + var cell = BuildCellWithFloor(); + // XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes). + var localFoot = new Vector3(20f, 20f, 0.5f); + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, localFoot, out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_NoWalkablePolys_ReturnsFalse() + { + // A polygon whose normal points sideways (wall) — normal.Z < 0.6664. + var wallPoly = new ResolvedPolygon + { + Vertices = new[] { Vector3.Zero, Vector3.UnitY, Vector3.UnitZ }, + Plane = new Plane(new Vector3(1f, 0f, 0f), 0f), // normal.Z = 0 + NumPoints = 3, + SidesType = CullMode.None, + }; + var cell = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [1] = wallPoly }, + }; + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse() + { + var cell = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_WithWorldTranslation_PlaneInWorldSpace() + { + // Cell is translated 100 units in X and 200 units in Y. + var translation = Matrix4x4.CreateTranslation(100f, 200f, 94f); + Matrix4x4.Invert(translation, out var inv); + + var localVerts = new[] + { + new Vector3(-5f, -5f, 0f), + new Vector3( 5f, -5f, 0f), + new Vector3( 5f, 5f, 0f), + new Vector3(-5f, 5f, 0f), + }; + var floorPoly = new ResolvedPolygon + { + Vertices = localVerts, + Plane = new Plane(new Vector3(0f, 0f, 1f), 0f), + NumPoints = 4, + SidesType = CullMode.None, + }; + var cell = new CellPhysics + { + WorldTransform = translation, + InverseWorldTransform = inv, + Resolved = new Dictionary { [0] = floorPoly }, + }; + + // The player's local foot is at (0,0,0.5) in local space. + var localFoot = new Vector3(0f, 0f, 0.5f); + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, localFoot, out var plane, out var worldVerts, out _); + + Assert.True(found); + // World normal should still be (0,0,1). + Assert.True(plane.Normal.Z > 0.99f); + // World vertex[0] should be at local (-5,-5,0) + translation = (95, 195, 94). + Assert.True(MathF.Abs(worldVerts[0].X - 95f) < 1e-3f); + Assert.True(MathF.Abs(worldVerts[0].Y - 195f) < 1e-3f); + Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f, + $"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}"); + } + + // ----------------------------------------------------------------------- + // PointInPolygonXY + // ----------------------------------------------------------------------- + + [Theory] + [InlineData( 0f, 0f, true)] // centre + [InlineData( 4f, 4f, true)] // near corner, inside + [InlineData( 5f, 5f, false)] // on the corner — outside by convention + [InlineData(10f, 0f, false)] // clearly outside + [InlineData(-4f, -4f, true)] // near opposite corner, inside + public void PointInPolygonXY_UnitSquare(float px, float py, bool expected) + { + var square = new[] + { + new Vector3(-5f, -5f, 0f), + new Vector3( 5f, -5f, 0f), + new Vector3( 5f, 5f, 0f), + new Vector3(-5f, 5f, 0f), + }; + bool result = Transition.PointInPolygonXY(new Vector3(px, py, 99f), square); + Assert.Equal(expected, result); + } + + [Fact] + public void PointInPolygonXY_IgnoresZ() + { + // Same XY, different Z — should still be inside. + var square = new[] + { + new Vector3(-5f, -5f, 0f), + new Vector3( 5f, -5f, 0f), + new Vector3( 5f, 5f, 0f), + new Vector3(-5f, 5f, 0f), + }; + // Point has the same XY as the inside case but a very different Z. + bool atLowZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, -1000f), square); + bool atHighZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, 1000f), square); + + Assert.True(atLowZ); + Assert.True(atHighZ); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index d9d08f8..9ee570c 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -207,7 +207,10 @@ public class PhysicsEngineTests Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 24.9f, 25.1f); - Assert.Equal(0x0009u, result.CellId); + // Phase D fix: ResolveOutdoorCellId now always applies the matched + // landblock's high-16 prefix — 0xA9B4 prefix from the registered + // landblock (0xA9B4FFFF) is now included in the returned CellId. + Assert.Equal(0xA9B40009u, result.CellId); } [Fact] @@ -228,7 +231,10 @@ public class PhysicsEngineTests Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 97.9f, 98.1f); - Assert.Equal(0x0025u, result.CellId); + // Phase D fix: ResolveOutdoorCellId now always applies the matched + // landblock's high-16 prefix — 0xA9B4 prefix from the registered + // landblock (0xA9B4FFFF) is now included in the returned CellId. + Assert.Equal(0xA9B40025u, result.CellId); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs b/tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs new file mode 100644 index 0000000..218309d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs @@ -0,0 +1,35 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class PortalInfoTests +{ + [Fact] + public void PortalSide_FlagsBit2Clear_ReturnsTrue() + { + var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0); + Assert.True(portal.PortalSide); + } + + [Fact] + public void PortalSide_FlagsBit2Set_ReturnsFalse() + { + 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() + { + var portal = new PortalInfo(otherCellId: 0xFFFF, polygonId: 5, flags: 0); + Assert.Equal((ushort)0xFFFF, portal.OtherCellId); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs new file mode 100644 index 0000000..c740e90 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs @@ -0,0 +1,44 @@ +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_NoLandblock_OutdoorSeed_ReturnsFallback() + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + // Outdoor seed with no landblock added → AddAllOutsideCells produces + // candidates but none have a CellBSP → falls back to input. + uint result = engine.ResolveCellId( + new Vector3(100, 100, 0), + sphereRadius: 0.5f, + fallbackCellId: 0xA9B40001u); + + Assert.Equal(0xA9B40001u, result); + } + + [Fact] + public void ResolveCellId_NoDataCache_ReturnsFallback() + { + // Build a PhysicsEngine without setting DataCache. + var engine = new PhysicsEngine { DataCache = null }; + uint result = engine.ResolveCellId( + new Vector3(100, 100, 0), + sphereRadius: 0.5f, + fallbackCellId: 0xA9B40100u); // indoor seed + // Indoor branch falls back when DataCache is null. + Assert.Equal(0xA9B40100u, result); + } +} diff --git a/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs b/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs new file mode 100644 index 0000000..3846663 --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using DatReaderWriter.Enums; +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, world transform = identity. + // Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation). + 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 = CullMode.None, + }; + 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()); + 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 = new CellPhysics + { + BSP = null, + Resolved = nearCell.Resolved, + WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0), + InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0), + }; + + 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}"); + } +} diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs new file mode 100644 index 0000000..91151db --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs @@ -0,0 +1,189 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.Enums; +using Xunit; + +namespace AcDream.Core.Tests.Selection; + +public class WorldPickerCellOcclusionTests +{ + private static CellPhysics MakeWallAtY10() + { + // A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space + // because WorldTransform = Identity). The occluder triangulates it as a fan: + // tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10. + 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 = CullMode.None, + }; + 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(), + }; + + /// + /// Builds a quad wall at Z=-10 in front of the camera (identity view, + /// camera looking down -Z). The wall spans X=-5..5, Y=-5..5 at Z=-10 — + /// large enough to cover the center-pixel ray. An entity at Z=-20 sits + /// behind it. + /// + /// Wall normal direction doesn't affect Möller-Trumbore (the occluder + /// is two-sided), but the Plane is stored for completeness. For a plane + /// at z=-10 with outward normal (0,0,+1): (0,0,1)·(x,y,-10) + D = 0 + /// → D = 10. + /// + private static CellPhysics MakeWallAtZNeg10() + { + var verts = new[] + { + new Vector3(-5, -5, -10), + new Vector3( 5, -5, -10), + new Vector3( 5, 5, -10), + new Vector3(-5, 5, -10), + }; + var poly = new ResolvedPolygon + { + Vertices = verts, + Plane = new System.Numerics.Plane(new Vector3(0, 0, 1), 10f), + NumPoints = 4, + SidesType = CullMode.None, + }; + return new CellPhysics + { + BSP = null, + Resolved = new() { [0] = poly }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + } + + // ────────────────────────────────────────────── + // Screen-rect overload + cell-BSP occlusion + // ────────────────────────────────────────────── + + /// + /// Production path exercised by GameWindow.PickAndStoreSelection. + /// Camera at origin looking down -Z (identity view). Entity at Z=-20 + /// projects to the center of the viewport. A wall at Z=-10 sits between + /// camera and entity; with cellOccluder wired up the entity must be + /// occluded → null result. + /// + /// This test specifically covers the clip.W depth-conversion math in + /// WorldPicker.Pick's screen-rect overload (issue #86). + /// + [Fact] + public void Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp() + { + // Use the same camera convention as WorldPickerRectOverloadTests.StdCam(): + // identity view, 90-degree FoV, 800×600 viewport. Center pixel = (400,300). + var view = Matrix4x4.Identity; + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f); + var viewport = new Vector2(800f, 600f); + + var wall = MakeWallAtZNeg10(); + var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20)); + + // Entity is dead-ahead: center of viewport. + var result = WorldPicker.Pick( + mouseX: 400f, mouseY: 300f, + view, proj, viewport, + candidates: new[] { entity }, + skipServerGuid: 0u, + sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f), + inflatePixels: 8f, + cellOccluder: (origin, direction) => + CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall })); + + Assert.Null(result); + } + + /// + /// Same camera and entity as Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp, + /// but with a null cellOccluder. Verifies that the no-occluder path still + /// resolves the entity to a hit (the new parameter is a pure no-op when null). + /// + [Fact] + public void Pick_ScreenRect_NoWall_HitsEntity() + { + var view = Matrix4x4.Identity; + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f); + var viewport = new Vector2(800f, 600f); + + var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20)); + + var result = WorldPicker.Pick( + mouseX: 400f, mouseY: 300f, + view, proj, viewport, + candidates: new[] { entity }, + skipServerGuid: 0u, + sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f), + inflatePixels: 8f, + cellOccluder: null); + + Assert.Equal(0xABCDu, result); + } + + // ────────────────────────────────────────────── + // Ray-sphere overload (legacy path) + // ────────────────────────────────────────────── + + [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); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs index 33b0fde..f89cb7f 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Combat; +using AcDream.Core.Physics; using AcDream.UI.Abstractions.Panels.Debug; namespace AcDream.UI.Abstractions.Tests.Panels.Debug; @@ -285,4 +286,26 @@ public sealed class DebugVMTests Assert.Equal(1, weatherHits); Assert.Equal(1, wireHits); } + + [Fact] + public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics() + { + var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled; + try + { + var vm = NewVm(); + + 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; + } + } }