Merge branch 'claude/competent-robinson-dec1f4' — Indoor walking Phase 1 + Phase 2

Cluster A (Indoor walking Phase 1 — BSP cluster):
- WorldPicker cell-BSP occlusion → #86 closed
- CellId promotion via AABB containment (partial Phase D fix)
- Diagnostic infrastructure: [indoor-bsp], [cell-cache] probes

Indoor walking Phase 2 (Portal-based cell tracking):
- CellBSP + Portals wired into CellPhysics
- CellTransit static class: FindTransitCellsSphere + AddAllOutsideCells + FindCellList
- ResolveCellId rename + sphereRadius plumbing
- BuildingPhysics + CheckBuildingTransit (outdoor→indoor entry)
- Foot-sphere center fix (made portal tracking actually work in production)
- Indoor walkable-plane synthesis (closes the falling-stuck bug)

Closes ISSUES.md #84, #85, #86, #87.
Files new issues #88 (indoor object vibration) + #89 (port SphereIntersectsCellBsp).

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Handoff: docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
This commit is contained in:
Erik 2026-05-19 19:34:13 +02:00
commit 1af49b710e
34 changed files with 6618 additions and 79 deletions

View file

@ -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

View file

@ -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

View file

@ -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<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` 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<Setup>(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<Setup>(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.

View file

@ -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.

View file

@ -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<PortalInfo>` 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.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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<PortalInfo>` from `envCell.CellPortals` |
| **`VisibleCellIds`** | **add (optional, deferred)** | `envCell.VisibleCells` keys — for future cleanup filter; populated but unused in this phase |
| **`PortalPolygons`** | **add** | `cellStruct.Polygons` resolved by id (separate from `PhysicsPolygons`; portals reference visible polys) |
**`PortalInfo`** (new readonly struct in `AcDream.Core.Physics`):
```csharp
public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)
{
/// <summary>Bit 2 of Flags. See research doc §"PortalSide flag semantics".</summary>
public bool PortalSide => (Flags & 2) == 0;
}
```
**`BuildingPhysics`** (new sealed class in `AcDream.Core.Physics`):
```csharp
public sealed class BuildingPhysics
{
public required Matrix4x4 WorldTransform;
public required Matrix4x4 InverseWorldTransform;
public required IReadOnlyList<BldPortalInfo> Portals;
}
public readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch);
```
One `BuildingPhysics` per outdoor landcell that contains a building stab. Used for outdoor→indoor entry.
### 4.2 Caching (extend `PhysicsDataCache`)
**`CacheCellStruct(envCellId, cellStruct, worldTransform)` — extended:**
After the existing `Resolved = ResolvePolygons(...)` step, also populate the new fields:
- `CellBSP = cellStruct.CellBSP` (verify field name during plan-writing; the DAT type may 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<uint>(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
{
/// <summary>
/// Top-level driver. Ported from retail CObjCell::find_cell_list (sphere variant).
/// Returns the cell id whose CellBSP contains the sphere center, or the original
/// fallback cell id if no cell matches.
/// </summary>
public static uint FindCellList(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out CellSet candidateSet);
/// <summary>
/// Indoor portal-neighbour expansion. Ported from CEnvCell::find_transit_cells
/// (sphere variant). For each portal of `currentCell`, tests whether the sphere
/// could overlap the neighbour cell and adds it to `candidateSet`.
/// </summary>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
ref CellSet candidateSet);
/// <summary>
/// Outdoor→indoor entry. Ported from BuildingObj::find_building_transit_cells +
/// CEnvCell::check_building_transit. For each BldPortal of `buildingPhysics`,
/// resolves the destination EnvCell and tests whether the sphere is inside it
/// via PointInsideCellBsp.
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics buildingPhysics,
Vector3 worldSphereCenter,
float sphereRadius,
ref CellSet candidateSet);
/// <summary>
/// Outdoor neighbour expansion. Ported from CLandCell::add_all_outside_cells.
/// Computes the player's 2D position within the 24×24m landcell and adds
/// neighbour landcells whose boundary the sphere crosses.
/// </summary>
public static void AddAllOutsideCells(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
ref CellSet candidateSet);
}
```
`CellSet` is a small helper — either `HashSet<uint>` or a thin wrapper allocating a stackalloc-backed list. Pick during plan-writing based on allocation profile.
### 4.4 `PhysicsEngine.ResolveCellId` (rename + rewrite)
Replaces `PhysicsEngine.ResolveOutdoorCellId`. New name + signature extended with a `sphereRadius` argument (needed by `FindTransitCellsSphere` for the sphere-vs-portal-plane test). Body becomes:
```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<EnvCell>(envCellId)
cellStruct = environment.Cells[envCell.CellStructure]
cellTransform = R(envCell.Position.Orientation) * T(envCell.Position.Origin + lbOffset + Z-bump)
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform)
│ populates: BSP, CellBSP, PhysicsPolygons, Vertices, WorldTransform,
│ InverseWorldTransform, Resolved, Portals, PortalPolygons,
│ VisibleCellIds
For each landcell containing a building (LandBlockInfo.Buildings):
_physicsDataCache.CacheBuilding(landcellId, building.Portals, buildingTransform)
│ populates: BldPortals list + buildingWorldTransform
```
### Movement tick (per substep)
```
PhysicsEngine.ResolveWithTransition starts
Transition.FindEnvCollisions:
sp.CheckCellId = ... (current cell estimate)
sphereRadius = sp.GlobalSphere[0].Radius
newCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId)
if newCellId != sp.CheckCellId:
sp.SetCheckPos(sp.CheckPos, newCellId)
Cell-BSP branch fires if sp.CheckCellId & 0xFFFF >= 0x0100
├── BSPQuery.FindCollisions(cellPhysics.BSP, ...) ← walls collide here
└── [indoor-bsp] probe emits a log line
Outdoor terrain collision (unchanged)
```
---
## 6. Commit shape (preview)
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, +XY, X+Y, XY 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).

View file

@ -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<Vector3, Vector3, float>?` 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<CellPhysics> 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<root cause from probe>
- One surgical change to TransitionTypes / GameWindow / BSPQuery
- Commit message cites probe evidence line
- Closes ISSUES.md #84
3. fix(physics): Cluster A #85<root cause from probe + decomp>
- 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/<ship-date>-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).

View file

@ -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<AcDream.Core.Physics.BldPortalInfo>(
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<AcDream.Core.Physics.CellPhysics>();
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)
{

View file

@ -928,16 +928,24 @@ public static class BSPQuery
// =========================================================================
/// <summary>
/// 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.
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
///
/// <para>ACE: BSPNode.cs point_inside_cell_bsp.</para>
/// </summary>
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;
}

View file

@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Enums;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
/// for outdoor→indoor cell entry. One per outdoor landcell that contains
/// a building stab. Mirrors retail's <c>BuildingObj.Portals</c> array
/// (per the pseudocode doc §"LandCell.find_transit_cells").
/// </summary>
public sealed class BuildingPhysics
{
public required Matrix4x4 WorldTransform { get; init; }
public required Matrix4x4 InverseWorldTransform { get; init; }
public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
}
/// <summary>
/// One building portal: the connection from a SortCell's BuildingObj to
/// an interior EnvCell. ExactMatch is decoded from <see cref="Flags"/>
/// bit 0 (<c>PortalFlags.ExactMatch = 0x0001</c>).
/// </summary>
public readonly struct BldPortalInfo
{
public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags)
{
OtherCellId = otherCellId;
OtherPortalId = otherPortalId;
Flags = flags;
}
/// <summary>Full id of the interior EnvCell this portal connects to.</summary>
public uint OtherCellId { get; }
/// <summary>The portal id within the destination EnvCell.</summary>
public ushort OtherPortalId { get; }
public ushort Flags { get; }
/// <summary>
/// Bit 0 of <see cref="Flags"/> (<c>DatReaderWriter.Enums.PortalFlags.ExactMatch</c>).
///
/// <para>
/// Reserved per retail's <c>CBldPortal::exact_match</c>. NOT currently
/// consumed by <see cref="CellTransit.CheckBuildingTransit"/> — 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.
/// </para>
/// </summary>
public bool ExactMatch => (Flags & (ushort)PortalFlags.ExactMatch) != 0;
}

View file

@ -0,0 +1,326 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
/// ported from retail's <c>CObjCell::find_cell_list</c> family
/// (sphere variant for the player's single foot sphere).
///
/// <para>
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
/// faithful point-in-cell tests via
/// <see cref="BSPQuery.PointInsideCellBsp"/>. Walks the portal graph
/// starting from a given current cell to find which cells a moving
/// sphere overlaps.
/// </para>
///
/// <para>
/// Reference pseudocode:
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
/// (2026-04-13). Retail decomp: <c>CEnvCell::find_transit_cells</c>
/// (sphere variant) at <c>acclient_2013_pseudo_c.txt</c>.
/// </para>
/// </summary>
public static class CellTransit
{
/// <summary>
/// Small radius padding matching retail's <c>EPSILON</c> usage in the
/// sphere-plane distance test (research doc §"EnvCell.find_transit_cells").
/// </summary>
private const float EPSILON = 0.02f;
/// <summary>
/// Indoor portal-neighbour expansion. For each portal of
/// <paramref name="currentCell"/>, test whether the sphere overlaps
/// the portal polygon's plane in cell-local space. If so, add the
/// neighbour cell to <paramref name="candidates"/>.
///
/// <para>
/// Ported from <c>CEnvCell::find_transit_cells</c> (sphere variant)
/// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)".
/// </para>
/// </summary>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
HashSet<uint> candidates,
out bool exitOutside)
{
exitOutside = false;
if (currentCell.PortalPolygons is null) return;
uint lbPrefix = currentCellId & 0xFFFF0000u;
float rad = sphereRadius + EPSILON;
// Cell-local sphere center.
var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform);
foreach (var portal in currentCell.Portals)
{
if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
continue;
// Signed distance from sphere center to portal plane (cell-local).
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
if (portal.OtherCellId == 0xFFFF)
{
// Exit portal. Sphere must straddle the plane.
if (dist > -rad && dist < rad)
exitOutside = true;
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);
}
}
/// <summary>
/// Outdoor neighbour expansion. Ported from
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant) per the
/// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
///
/// <para>
/// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
/// within a landblock is computed from local X/Y mod 24. The sphere
/// adds the primary cell plus up to 3 neighbours when the radius
/// reaches a cell boundary.
/// </para>
/// </summary>
public static void AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
HashSet<uint> candidates)
{
const float CellSize = 24f;
uint lbPrefix = currentCellId & 0xFFFF0000u;
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<uint> candidates, uint lbPrefix, int gridX, int gridY)
{
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
// Cell index within landblock: row-major (X * 8 + Y) + 1.
uint low = (uint)(gridX * 8 + gridY + 1);
candidates.Add(lbPrefix | low);
}
/// <summary>
/// Outdoor→indoor entry path. Ported from retail's
/// <c>BuildingObj::find_building_transit_cells</c> +
/// <c>EnvCell::check_building_transit</c>. For each portal of the
/// outdoor building, look up the destination interior cell and test
/// whether the sphere center is inside it via
/// <see cref="BSPQuery.PointInsideCellBsp"/>. If so, add the interior
/// cell to <paramref name="candidates"/>.
///
/// <para>
/// <b>Retail divergence:</b> retail's <c>check_building_transit</c>
/// uses <c>CCellStruct::sphere_intersects_cell</c> (radius-aware
/// BSP-vs-sphere test) which fires the moment ANY part of the sphere
/// overlaps the destination cell. Our port uses
/// <see cref="BSPQuery.PointInsideCellBsp"/> (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 <paramref name="sphereRadius"/> (~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 <c>sphere_intersects_cell</c> in a follow-up.
/// <paramref name="sphereRadius"/> is plumbed through for that future
/// upgrade; currently unused.
/// </para>
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
Vector3 worldSphereCenter,
float sphereRadius,
HashSet<uint> 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);
}
}
}
/// <summary>
/// Top-level cell-tracking driver, ported from retail's
/// <c>CObjCell::find_cell_list</c> (sphere variant).
///
/// <para>
/// Walks the portal graph from <paramref name="currentCellId"/>,
/// finds the cell whose <see cref="CellPhysics.CellBSP"/> contains
/// the sphere center, and returns its full id (landblock-prefixed).
/// Falls back to <paramref name="currentCellId"/> when no candidate
/// matches.
/// </para>
///
/// <para>
/// Pseudocode reference:
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
/// §"Overall Driver: find_cell_list".
/// </para>
/// </summary>
public static uint FindCellList(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId)
{
var candidates = new HashSet<uint>();
uint currentLow = currentCellId & 0xFFFFu;
if (currentLow >= 0x0100u)
{
// Indoor seed.
var currentCell = cache.GetCellStruct(currentCellId);
if (currentCell is null) return currentCellId;
candidates.Add(currentCellId);
// BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
var pending = new Queue<uint>();
var visited = new HashSet<uint>();
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<uint>(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;
}
}

View file

@ -20,6 +20,9 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
/// <summary>
/// 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.
/// </summary>
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<PortalInfo>(envCell.CellPortals.Count);
foreach (var p in envCell.CellPortals)
{
portals.Add(new PortalInfo(
otherCellId: p.OtherCellId,
polygonId: p.PolygonId,
flags: (ushort)p.Flags));
}
// VisibleCells set — populated for future use; not consulted this phase.
// envCell.VisibleCells is List<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
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<DatReaderWriter.Types.PhysicsBSPNode>();
stack.Push(root);
while (stack.Count > 0)
{
var n = stack.Pop();
if (n.Polygons is not null)
{
foreach (var pid in n.Polygons)
{
bspTotalLeafPolys++;
if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
}
}
if (n.PosNode is not null) stack.Push(n.PosNode);
if (n.NegNode is not null) stack.Push(n.NegNode);
}
}
var bs = root?.BoundingSphere;
string bsStr = bs is null
? "bsphere=n/a"
: System.FormattableString.Invariant(
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
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})"));
}
}
/// <summary>
@ -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.
/// </summary>
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
@ -210,6 +283,15 @@ public sealed class PhysicsDataCache
public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
/// 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.
/// </summary>
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
@ -217,6 +299,39 @@ public sealed class PhysicsDataCache
/// </summary>
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
=> _gfxObj[gfxObjId] = physics;
/// <summary>
/// Register a pre-built <see cref="CellPhysics"/> directly. Intended for
/// unit-test fixtures that construct synthetic cells without going through
/// dat-driven <see cref="CacheCellStruct"/>.
/// </summary>
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
=> _cellStruct[envCellId] = physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
/// for an outdoor landcell that contains a building stab. Used by
/// <see cref="CellTransit.CheckBuildingTransit"/>.
/// </summary>
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
{
if (_buildings.ContainsKey(landcellId)) return;
Matrix4x4.Invert(worldTransform, out var inverse);
_buildings[landcellId] = new BuildingPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inverse,
Portals = portals,
};
}
public BuildingPhysics? GetBuilding(uint landcellId)
=> _buildings.TryGetValue(landcellId, out var b) ? b : null;
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
}
/// <summary>
@ -285,9 +400,15 @@ public sealed class SetupPhysics
/// </summary>
public sealed class CellPhysics
{
public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public required VertexArray Vertices { get; init; }
/// <summary>
/// The physics BSP tree for this cell. Nullable so that test fixtures
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
/// alone without needing a real DAT BSP object. Production code must
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
/// </summary>
public PhysicsBSPTree? BSP { get; init; }
public Dictionary<ushort, Polygon>? 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.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
/// </summary>
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
/// <summary>
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
/// holds <c>PhysicsPolygons</c>. Portal lookup via
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
/// Nullable when the cell has no visible polys (rare).
/// </summary>
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
/// <summary>
/// The full cell ids visible from this cell (with landblock prefix).
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
/// this phase; reserved for the optional <c>find_cell_list</c>
/// visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
}

View file

@ -166,4 +166,59 @@ public static class PhysicsDiagnostics
/// </summary>
public static bool DumpSteepRoofEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). When true, emits one
/// <c>[indoor-bsp]</c> line per <see cref="BSPQuery.FindCollisions"/>
/// call made from <see cref="Transition.FindEnvCollisions"/>'s indoor
/// cell-BSP branch. Captures the cell id, sphere local position,
/// resulting <see cref="TransitionState"/>, and the hit poly's id,
/// local-normal, and side-type — pinpoints why indoor collision
/// returns spurious collisions (#84) and helps cross-check the
/// outdoor-in approach path (#85).
///
/// <para>
/// While true, this also un-gates the diagnostic
/// <see cref="LastBspHitPoly"/> side-channel inside
/// <see cref="BSPQuery"/> — see the OR'd condition at every poly
/// write site. Zero-cost when off.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
/// Runtime-toggleable via DebugPanel.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeIndoorBspEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
/// <summary>
/// Indoor walking Phase D follow-up (2026-05-19). When true, emits one
/// <c>[cell-cache]</c> line each time <see cref="PhysicsDataCache.CacheCellStruct"/>
/// caches a new EnvCell. Reports per-cell polygon counts and BSP root
/// structure so the caller can cross-reference with <c>[indoor-bsp]</c>
/// lines to distinguish between:
/// <list type="bullet">
/// <item><description>Empty data (physicsPolyCount=0 or resolvedCount=0)
/// — candidate (a)/(c) in the poly=n/a investigation.</description></item>
/// <item><description>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.</description></item>
/// <item><description>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).</description></item>
/// </list>
/// 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.
///
/// <para>Initial state from <c>ACDREAM_PROBE_CELL_CACHE=1</c>.</para>
/// </summary>
public static bool ProbeCellCacheEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1";
}

View file

@ -230,20 +230,43 @@ public sealed class PhysicsEngine
}
/// <summary>
/// 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.
///
/// <para>
/// Indoor seed: delegates to <see cref="CellTransit.FindCellList"/> which
/// BFS-walks the portal graph and uses <see cref="BSPQuery.PointInsideCellBsp"/>
/// for containment. This replaces Phase D's AABB shortcut.
/// </para>
///
/// <para>
/// 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).
/// </para>
///
/// <para>
/// Design: <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
/// </para>
/// </summary>
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<uint>();
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);

View file

@ -0,0 +1,45 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
/// EnvCells. Each <see cref="CellPhysics"/> carries a list of these,
/// mirroring retail's <c>CCellStruct.portals</c> array.
///
/// <para>
/// <see cref="OtherCellId"/> is a low-16 cell index (combined with the
/// owning landblock prefix at lookup time) or <c>0xFFFF</c> to mean
/// "exit to outdoor world" (the player crosses this portal to leave
/// the building).
/// </para>
///
/// <para>
/// <see cref="PolygonId"/> indexes the OWNING cell's
/// <see cref="CellPhysics.PortalPolygons"/> dict (the visible-polygon
/// table, NOT <see cref="CellPhysics.Resolved"/> which holds physics
/// polys).
/// </para>
///
/// <para>
/// <see cref="PortalSide"/> decodes bit 2 of <see cref="Flags"/>:
/// <c>(Flags &amp; 2) == 0</c> → portal's polygon normal points INTO
/// the owning cell (so dist &gt; 0 in cell-local space means "outside
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
/// load-hint path for unloaded neighbours.
/// </para>
/// </summary>
public readonly struct PortalInfo
{
public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
{
OtherCellId = otherCellId;
PolygonId = polygonId;
Flags = flags;
}
public ushort OtherCellId { get; }
public ushort PolygonId { get; }
public ushort Flags { get; }
/// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
public bool PortalSide => (Flags & 2) == 0;
}

View file

@ -1166,6 +1166,92 @@ public sealed class Transition
// Environment collision — outdoor terrain
// -----------------------------------------------------------------------
/// <summary>
/// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor
/// polygon directly under <paramref name="localFootCenter"/> within
/// <paramref name="cellPhysics"/>. 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 <c>GameWindow.BuildInteriorEntitiesForStreaming</c>).
///
/// <para>
/// Iterates <see cref="CellPhysics.Resolved"/> physics polygons; selects
/// the one with the most upward-facing normal (Z &gt;= 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 <c>ValidateWalkable</c> call.
/// </para>
///
/// <para>
/// Returns <c>false</c> 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).
/// </para>
/// </summary>
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<Vector3>();
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;
}
/// <summary>
/// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting
/// even-odd rule. Works for convex and concave polygons.
/// </summary>
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;
}
/// <summary>
/// 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.
}
}

View file

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Selection;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
/// (currently-loaded EnvCells with resolved polygon planes), returns
/// the nearest world-space <c>t</c> along the ray that hits any cell
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
/// all cells.
///
/// <para>
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
/// ray-triangle intersection; one test per triangle. Cells are
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
/// so the ray runs in cell-local space and the resolved-polygon
/// vertices don't need re-transformation per query.
/// </para>
///
/// <para>
/// No BSP traversal — iterates every polygon in every cell. Cell count
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
/// </para>
/// </summary>
public static class CellBspRayOccluder
{
/// <summary>
/// Returns the nearest positive <c>t</c> such that
/// <c>origin + t * direction</c> intersects a polygon in any cell.
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
/// is intersected.
/// </summary>
/// <param name="direction">Need not be normalized; returned <c>t</c>
/// scales with direction length the same as a parametric ray.</param>
public static float NearestWallT(
Vector3 origin,
Vector3 direction,
IEnumerable<CellPhysics> loadedCells)
{
if (loadedCells is null) return float.PositiveInfinity;
float bestT = float.PositiveInfinity;
foreach (var cell in loadedCells)
{
if (cell?.Resolved is null) continue;
// Bring the ray into cell-local space ONCE per cell.
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
foreach (var (_, poly) in cell.Resolved)
{
// Triangulate the (possibly polygonal) face into a fan.
int n = poly.NumPoints;
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
continue;
for (int i = 1; i < n - 1; i++)
{
if (TryRayTriangle(
localOrigin, localDirection,
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
out var t)
&& t < bestT)
{
bestT = t;
}
}
}
}
return bestT;
}
/// <summary>
/// Möller-Trumbore ray-triangle intersection. Returns true with
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
/// at a positive distance.
/// </summary>
private static bool TryRayTriangle(
Vector3 origin, Vector3 direction,
Vector3 v0, Vector3 v1, Vector3 v2,
out float t)
{
const float Epsilon = 1e-7f;
var edge1 = v1 - v0;
var edge2 = v2 - v0;
var pvec = Vector3.Cross(direction, edge2);
float det = Vector3.Dot(edge1, pvec);
// No two-sided handling here — picker should be permissive so
// a wall blocks regardless of which side the camera is on.
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
float invDet = 1f / det;
var tvec = origin - v0;
float u = Vector3.Dot(tvec, pvec) * invDet;
if (u < 0f || u > 1f) { t = 0f; return false; }
var qvec = Vector3.Cross(tvec, edge1);
float v = Vector3.Dot(direction, qvec) * invDet;
if (v < 0f || u + v > 1f) { t = 0f; return false; }
t = Vector3.Dot(edge2, qvec) * invDet;
return t > Epsilon;
}
}

View file

@ -91,13 +91,20 @@ public static class WorldPicker
uint skipServerGuid,
float maxDistance = 50f,
Func<uint, float>? radiusForGuid = null,
Func<uint, float>? verticalOffsetForGuid = null)
Func<uint, float>? verticalOffsetForGuid = null,
Func<Vector3, Vector3, float>? 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<WorldEntity> candidates,
uint skipServerGuid,
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
float inflatePixels = 8f)
float inflatePixels = 8f,
Func<Vector3, Vector3, float>? 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;

View file

@ -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

View file

@ -345,6 +345,20 @@ public sealed class DebugVM
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
}
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Runtime mirror of
/// <c>PhysicsDiagnostics.ProbeIndoorBspEnabled</c> (env var
/// <c>ACDREAM_PROBE_INDOOR_BSP</c>). Toggling here flips the
/// <c>[indoor-bsp]</c> probe live — no relaunch required.
/// Physics-side companion to the five render-side
/// <c>ProbeIndoor*</c> mirrors directly above.
/// </summary>
public bool ProbeIndoorBsp
{
get => PhysicsDiagnostics.ProbeIndoorBspEnabled;
set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
/// five indoor probes together. No dedicated env var; set any individual

View file

@ -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<ushort, ResolvedPolygon>(),
};
Assert.Null(cp.CellBSP);
Assert.Empty(cp.Portals);
Assert.Null(cp.PortalPolygons);
Assert.Empty(cp.VisibleCellIds);
}
[Fact]
public void NewFields_AcceptInitValues()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
Portals = new[] { portal },
VisibleCellIds = new System.Collections.Generic.HashSet<uint> { 0xA9B40101 },
};
Assert.Single(cp.Portals);
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
Assert.Contains(0xA9B40101u, cp.VisibleCellIds);
}
[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<ushort, ResolvedPolygon>(),
Portals = portals,
};
Assert.Equal(2, cp.Portals.Count);
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
Assert.True(cp.Portals[0].PortalSide);
Assert.Equal((ushort)0xFFFF, cp.Portals[1].OtherCellId);
Assert.False(cp.Portals[1].PortalSide);
}
}

View file

@ -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<uint>();
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<uint>();
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);
}
}

View file

@ -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<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
var candidates = new HashSet<uint>();
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.
}

View file

@ -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);
}
}

View file

@ -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<ushort, ResolvedPolygon>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [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<ushort, ResolvedPolygon>(),
};
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<uint>();
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<uint>();
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<uint>();
CellTransit.FindTransitCellsSphere(
cache, exitCell, currentCellId: 0xA9B40100u,
worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside);
Assert.True(exitOutside);
}
}

View file

@ -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;
/// <summary>
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/> and
/// <see cref="Transition.PointInPolygonXY"/>.
///
/// 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.
/// </summary>
public class IndoorWalkablePlaneTests
{
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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<ushort, ResolvedPolygon> { [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<ushort, ResolvedPolygon> { [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<ushort, ResolvedPolygon>(),
};
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<ushort, ResolvedPolygon> { [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);
}
}

View file

@ -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]

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<CellPhysics>());
Assert.True(float.IsPositiveInfinity(t));
}
[Fact]
public void NearestWallT_TwoCells_ReturnsNearer()
{
var nearCell = MakeWallCell(); // wall at Y=10
var farCell = MakeWallCell();
// Move farCell's transform to push it to Y=20.
farCell = 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}");
}
}

View file

@ -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<MeshRef>(),
};
/// <summary>
/// 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.
/// </summary>
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
// ──────────────────────────────────────────────
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
}

View file

@ -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;
}
}
}