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:
commit
1af49b710e
34 changed files with 6618 additions and 79 deletions
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -776,7 +776,36 @@ acdream's plan lives in two files committed to the repo:
|
||||||
acceptance criteria. Do not drift from the spec without explicit user
|
acceptance criteria. Do not drift from the spec without explicit user
|
||||||
approval.
|
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 +
|
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.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13;
|
||||||
**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
|
**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
|
||||||
|
|
|
||||||
153
docs/ISSUES.md
153
docs/ISSUES.md
|
|
@ -308,9 +308,10 @@ to the second floor without getting stuck.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #84 — Blocked by air indoors
|
## #84 — [DONE 2026-05-19] Blocked by air indoors
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** DONE
|
||||||
|
**Closed:** 2026-05-19
|
||||||
**Severity:** HIGH (blocks indoor navigation)
|
**Severity:** HIGH (blocks indoor navigation)
|
||||||
**Filed:** 2026-05-19
|
**Filed:** 2026-05-19
|
||||||
**Component:** physics, collision
|
**Component:** physics, collision
|
||||||
|
|
@ -337,57 +338,126 @@ visible cell mesh. Possibilities:
|
||||||
**Acceptance:** Walking through interior cell space hits collisions
|
**Acceptance:** Walking through interior cell space hits collisions
|
||||||
only where visible walls/furniture exist.
|
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
|
**Status:** DONE
|
||||||
**Severity:** HIGH (gameplay-breaking)
|
**Closed:** 2026-05-19
|
||||||
|
**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
|
||||||
**Filed:** 2026-05-19
|
**Filed:** 2026-05-19
|
||||||
**Component:** physics, collision
|
**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
|
can walk THROUGH walls into the interior — one-directional wall
|
||||||
collision. From the inside trying to exit, the wall does block.
|
collision. From the inside trying to exit, the wall does block.
|
||||||
|
|
||||||
**Root cause / status:** Cell BSP polygons likely have one-sided
|
The root cause was pinned (Cluster A 2026-05-19) as the same failure as
|
||||||
normals (front-facing only). Approach from the inside hits the front;
|
#84's remaining symptom — `CellId` wasn't promoted to the indoor cell
|
||||||
approach from the outside hits the back which BSP traversal treats as
|
during normal outdoor→indoor walking because AABB containment was too
|
||||||
"behind the plane" → no collision. Retail handles this via two-sided
|
tight for threshold/doorway cells. Without CellId in the indoor cell,
|
||||||
collision polys or per-poly back-face handling.
|
the indoor-BSP collision branch in `FindEnvCollisions` never fired
|
||||||
|
regardless of approach direction.
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #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
|
**Status:** OPEN
|
||||||
**Severity:** MEDIUM
|
**Severity:** MEDIUM (visual jitter; doesn't block gameplay)
|
||||||
**Filed:** 2026-05-19
|
**Filed:** 2026-05-19
|
||||||
**Component:** input, interaction
|
**Component:** rendering, animation
|
||||||
|
|
||||||
**Description:** Clicking through a wall from the outside selects NPCs
|
**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:
|
||||||
and objects inside the building. The `WorldPicker` raycast doesn't
|
|
||||||
intersect cell BSP geometry.
|
|
||||||
|
|
||||||
**Root cause / status:** `WorldPicker.BuildRay + Pick` (introduced in
|
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).
|
||||||
Phase B.4) tests against entity AABBs and scenery BSPs but probably
|
2. Per-part transforms for cell-static `WorldEntity` instances getting recomputed each frame with floating-point drift.
|
||||||
not cell BSP. Outdoor NPCs are pickable because their entity AABB is
|
3. Particle-emitter offsets accumulating instead of resetting.
|
||||||
the test target; indoor NPCs are pickable from outside because the
|
|
||||||
wall isn't in the ray's intersection set.
|
**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:**
|
**Files:**
|
||||||
- `src/AcDream.App/Rendering/WorldPicker.cs` (or equivalent — check
|
- `src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit` (line ~162)
|
||||||
Phase B.4b reference).
|
- `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
|
# 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
|
## #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
|
**Closed:** 2026-05-18
|
||||||
|
|
|
||||||
|
|
@ -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 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 ✓ |
|
| 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 ✓ |
|
| 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:
|
Plus polish that doesn't get its own phase number:
|
||||||
- FlyCamera default speed lowered + Shift-to-boost
|
- 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.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.
|
- **✓ 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.
|
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
|
||||||
|
|
||||||
|
|
|
||||||
256
docs/research/2026-05-19-cluster-a-shipped-handoff.md
Normal file
256
docs/research/2026-05-19-cluster-a-shipped-handoff.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
1846
docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Normal file
1846
docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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, +X−Y, −X+Y, −X−Y of a 24m cell → 1, 2, or 4 cells in the result set.
|
||||||
|
- **`CellTransitFindCellListTests`** — integration:
|
||||||
|
- Indoor seed → returns matching indoor cell after portal walk.
|
||||||
|
- Outdoor seed → returns matching landcell.
|
||||||
|
- Outdoor seed near building portal → returns indoor cell via `check_building_transit`.
|
||||||
|
- Indoor seed crossing exit portal → returns outdoor landcell.
|
||||||
|
|
||||||
|
### Rewritten tests
|
||||||
|
|
||||||
|
- The four `ResolveOutdoorCellIdIndoorContainmentTests` (Phase D) — same scenarios, but using the portal-traversal mechanism rather than synthetic AABB-only cells. Some may merge with `CellTransitFindCellListTests`.
|
||||||
|
|
||||||
|
### Live test (user-driven)
|
||||||
|
|
||||||
|
Same launch incantation as Phase E:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
|
||||||
|
$env:ACDREAM_PROBE_RESOLVE = "1"
|
||||||
|
$env:ACDREAM_PROBE_CELL = "1"
|
||||||
|
$env:ACDREAM_PROBE_CELL_CACHE = "1"
|
||||||
|
$env:ACDREAM_DEVTOOLS = "1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Walk the Holtburg cottage end-to-end. Verify all four acceptance criteria below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Acceptance
|
||||||
|
|
||||||
|
1. **Indoor walking** — Player walks inside the Holtburg cottage freely; walls block from inside (current bug fixed); furniture still collides (no regression from per-object collision).
|
||||||
|
2. **Outdoor→indoor** — Player walks toward the cottage door from outside; CellId promotes to an indoor cell when crossing the doorway; walls beyond the door block.
|
||||||
|
3. **Indoor→outdoor** — Player walks back out through the door; CellId demotes to the outdoor landcell; outdoor terrain collision resumes; ACE doesn't report cell-state desync.
|
||||||
|
4. **Indoor→indoor** — Player walks from one room to another through an interior doorway; CellId transitions correctly between EnvCells; no momentary "stuck on portal plane" issues.
|
||||||
|
5. **`[indoor-bsp]` probe fires consistently** during indoor walking — not just during jumps (the Phase D failure mode).
|
||||||
|
6. **`dotnet build` + `dotnet test`** green with the new test suite. Pre-existing baseline of 8 failures unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Out of scope (deferred / explicit non-goals)
|
||||||
|
|
||||||
|
- **Parts/AABB variant of `find_transit_cells`** — used for creatures and large objects with multi-part bounding boxes. Only the player's single-sphere case is in scope here; the AABB variant ports as a follow-up if remote-entity cell tracking proves broken.
|
||||||
|
- **`VisibleCells` cleanup filter** — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skipped; the BSP point-in-cell already picks one winner. Data is populated for future use.
|
||||||
|
- **Multi-portal crossings within a single movement step** — retail's resolver handles fast movement crossing multiple portals via the per-substep loop. We rely on the per-substep loop being fine-grained enough; if a regression surfaces, address as a follow-up.
|
||||||
|
- **Unification with `LoadedCell.Portals` in `AcDream.App.Rendering`** — two parallel portal stores remain (Core for collision, App for visibility). Future cleanup could unify them, but not in this phase.
|
||||||
|
- **`CellTransit` for moving entities other than the player** — the function works for any sphere, but only the player's resolve path is wired this phase. Remote-entity cell tracking remains as-is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Risks
|
||||||
|
|
||||||
|
1. **DAT field name mismatch.** The pseudocode doc references `CellStruct.CellBSP` but DatReaderWriter may name it differently (e.g. `cell_bsp`, `CellBsp`, `CellTree`). Verify at plan-writing time by reading DatReaderWriter's `CellStruct.cs` (NuGet source). If the field is missing entirely, file a sub-phase to extend DatReaderWriter — but this is unlikely given the dat format includes the BSP.
|
||||||
|
2. **`BuildingObj.Portals` structure differs from indoor portals.** Retail's `BldPortal` has more fields (`OtherPortalId`, `ExactMatch`). The DAT representation lives under `LandBlockInfo.Buildings[...]`; verify the field shape at plan-writing time.
|
||||||
|
3. **Sphere radius plumbing.** `FindTransitCellsSphere` needs the player's sphere radius to test against the portal plane. The caller (`Transition.FindEnvCollisions`) has access via `sp.GlobalSphere[0].Radius`; plumb it through `ResolveCellId`'s signature in the same commit that wires the call.
|
||||||
|
4. **Rename cost.** Renaming `ResolveOutdoorCellId` → `ResolveCellId` cascades through 4 call sites + test names + commit messages. Bundling the rename with the wiring commit keeps the change atomic; spreading it across commits creates a transient state where the function name doesn't match its behavior.
|
||||||
|
5. **Phase D test rewrites.** The 4 Phase D tests assert AABB-containment behavior that no longer exists. Rewriting them to use the portal-traversal mechanism requires synthetic test fixtures with portals + CellBSP — more setup boilerplate. Acceptable cost; integration coverage improves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Phase name + roadmap placement
|
||||||
|
|
||||||
|
**Proposed name:** "Indoor portal-based cell tracking" (sometimes abbreviated "Indoor walking Phase 2" since it follows Cluster A / Indoor walking Phase 1).
|
||||||
|
|
||||||
|
**Roadmap placement:** add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Sits in front of any remaining indoor-rendering polish (issues #78, #79-#82) since indoor walking is the gating issue.
|
||||||
|
|
||||||
|
**Milestone:** still parallel to M2 (Kill a drudge). Completing indoor walking unblocks demos that involve buildings (e.g. talking to interior NPCs, picking up items from inside shops).
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -5381,7 +5381,7 @@ public sealed class GameWindow : IDisposable
|
||||||
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
|
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
|
||||||
|
|
||||||
// Cache CellStruct physics BSP for indoor collision.
|
// 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,
|
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
|
||||||
portalPlanes, origin.X, origin.Y);
|
portalPlanes, origin.X, origin.Y);
|
||||||
}
|
}
|
||||||
|
|
@ -9131,6 +9174,17 @@ public sealed class GameWindow : IDisposable
|
||||||
var camera = _cameraController.Active;
|
var camera = _cameraController.Active;
|
||||||
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
|
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(
|
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||||
view: camera.View, projection: camera.Projection,
|
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
|
// Match the indicator's TriangleSize (8 px) so the click area
|
||||||
// extends out to the bracket corners — what the user perceives
|
// extends out to the bracket corners — what the user perceives
|
||||||
// as "selectable extent."
|
// 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)
|
if (picked is uint guid)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -928,16 +928,24 @@ public static class BSPQuery
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// <para>
|
||||||
/// Follows the front side of each splitting plane. A point is inside when it
|
/// Indoor walking Phase 2 (2026-05-19): retyped from PhysicsBSPNode? to
|
||||||
/// reaches a front leaf or null PosNode (solid interior).
|
/// 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>
|
||||||
///
|
///
|
||||||
/// <para>ACE: BSPNode.cs point_inside_cell_bsp.</para>
|
/// <para>ACE: BSPNode.cs point_inside_cell_bsp.</para>
|
||||||
/// </summary>
|
/// </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 is null) return true;
|
||||||
if (node.Type == BSPNodeType.Leaf) return true;
|
if (node.Type == BSPNodeType.Leaf) return true;
|
||||||
|
|
@ -1215,7 +1223,7 @@ public static class BSPQuery
|
||||||
{
|
{
|
||||||
collisions.SetCollisionNormal(collisionNormal);
|
collisions.SetCollisionNormal(collisionNormal);
|
||||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||||
return TransitionState.Collided;
|
return TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
@ -1228,14 +1236,14 @@ public static class BSPQuery
|
||||||
// the early-out — collisions.SetCollisionNormal isn't called on
|
// the early-out — collisions.SetCollisionNormal isn't called on
|
||||||
// this path, but the caller's CollisionInfo.CollisionNormalValid
|
// this path, but the caller's CollisionInfo.CollisionNormalValid
|
||||||
// check will catch the parent slide site's normal write instead.
|
// check will catch the parent slide site's normal write instead.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||||
return TransitionState.Collided;
|
return TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
||||||
collisions.SetCollisionNormal(collisionNormal);
|
collisions.SetCollisionNormal(collisionNormal);
|
||||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||||
|
|
||||||
var adjusted = validPos.Center - checkPos.Center;
|
var adjusted = validPos.Center - checkPos.Center;
|
||||||
|
|
@ -1551,7 +1559,7 @@ public static class BSPQuery
|
||||||
// is the dominant grounded-player path; without this the
|
// is the dominant grounded-player path; without this the
|
||||||
// probe's [resolve-bldg] line for every grounded BSP hit was
|
// probe's [resolve-bldg] line for every grounded BSP hit was
|
||||||
// mis-labeled as "n/a (cylinder)".
|
// mis-labeled as "n/a (cylinder)".
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||||
|
|
||||||
var worldNormal = L2W(hitPoly0!.Plane.Normal);
|
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
|
// L.2d slice 1.5 (2026-05-13): same early-record as foot
|
||||||
// sphere — head-sphere wall hits also recurse via
|
// sphere — head-sphere wall hits also recurse via
|
||||||
// StepSphereUp on the grounded path.
|
// StepSphereUp on the grounded path.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
|
|
||||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||||
|
|
@ -1669,7 +1677,7 @@ public static class BSPQuery
|
||||||
collisions.SetCollisionNormal(worldNormal0);
|
collisions.SetCollisionNormal(worldNormal0);
|
||||||
collisions.SetSlidingNormal(worldNormal0);
|
collisions.SetSlidingNormal(worldNormal0);
|
||||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
@ -1679,7 +1687,7 @@ public static class BSPQuery
|
||||||
path.SetCollide(worldNormal0);
|
path.SetCollide(worldNormal0);
|
||||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
@ -1709,7 +1717,7 @@ public static class BSPQuery
|
||||||
collisions.SetCollisionNormal(worldNormal1);
|
collisions.SetCollisionNormal(worldNormal1);
|
||||||
collisions.SetSlidingNormal(worldNormal1);
|
collisions.SetSlidingNormal(worldNormal1);
|
||||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
@ -1718,7 +1726,7 @@ public static class BSPQuery
|
||||||
path.SetCollide(worldNormal1);
|
path.SetCollide(worldNormal1);
|
||||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/AcDream.Core/Physics/BuildingPhysics.cs
Normal file
52
src/AcDream.Core/Physics/BuildingPhysics.cs
Normal 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;
|
||||||
|
}
|
||||||
326
src/AcDream.Core/Physics/CellTransit.cs
Normal file
326
src/AcDream.Core/Physics/CellTransit.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,9 @@ public sealed class PhysicsDataCache
|
||||||
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
||||||
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = 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>
|
/// <summary>
|
||||||
/// Extract and cache the physics BSP + polygon data from a GfxObj,
|
/// Extract and cache the physics BSP + polygon data from a GfxObj,
|
||||||
/// PLUS always cache a visual AABB from the vertex data regardless of
|
/// 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
|
/// (indoor room geometry). No-ops if the id is already cached or the
|
||||||
/// CellStruct has no physics BSP.
|
/// CellStruct has no physics BSP.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void CacheCellStruct(uint envCellId, CellStruct cellStruct,
|
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
|
||||||
Matrix4x4 worldTransform)
|
CellStruct cellStruct, Matrix4x4 worldTransform)
|
||||||
{
|
{
|
||||||
if (_cellStruct.ContainsKey(envCellId)) return;
|
if (_cellStruct.ContainsKey(envCellId)) return;
|
||||||
if (cellStruct.PhysicsBSP?.Root is null) return;
|
if (cellStruct.PhysicsBSP?.Root is null) return;
|
||||||
|
|
||||||
Matrix4x4.Invert(worldTransform, out var inverseTransform);
|
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
|
_cellStruct[envCellId] = new CellPhysics
|
||||||
{
|
{
|
||||||
BSP = cellStruct.PhysicsBSP,
|
BSP = cellStruct.PhysicsBSP,
|
||||||
|
|
@ -143,8 +171,53 @@ public sealed class PhysicsDataCache
|
||||||
Vertices = cellStruct.VertexArray,
|
Vertices = cellStruct.VertexArray,
|
||||||
WorldTransform = worldTransform,
|
WorldTransform = worldTransform,
|
||||||
InverseWorldTransform = inverseTransform,
|
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>
|
/// <summary>
|
||||||
|
|
@ -152,7 +225,7 @@ public sealed class PhysicsDataCache
|
||||||
/// and compute the face plane. Matches ACE's Polygon constructor which calls
|
/// and compute the face plane. Matches ACE's Polygon constructor which calls
|
||||||
/// make_plane() and resolves Vertices from VertexIDs at load time.
|
/// make_plane() and resolves Vertices from VertexIDs at load time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
|
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
|
||||||
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
|
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
|
||||||
VertexArray vertexArray)
|
VertexArray vertexArray)
|
||||||
{
|
{
|
||||||
|
|
@ -210,6 +283,15 @@ public sealed class PhysicsDataCache
|
||||||
public int SetupCount => _setup.Count;
|
public int SetupCount => _setup.Count;
|
||||||
public int CellStructCount => _cellStruct.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>
|
/// <summary>
|
||||||
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
||||||
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
||||||
|
|
@ -217,6 +299,39 @@ public sealed class PhysicsDataCache
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
|
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
|
||||||
=> _gfxObj[gfxObjId] = 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>
|
/// <summary>
|
||||||
|
|
@ -285,9 +400,15 @@ public sealed class SetupPhysics
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CellPhysics
|
public sealed class CellPhysics
|
||||||
{
|
{
|
||||||
public required PhysicsBSPTree BSP { get; init; }
|
/// <summary>
|
||||||
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
|
/// The physics BSP tree for this cell. Nullable so that test fixtures
|
||||||
public required VertexArray Vertices { get; init; }
|
/// 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 WorldTransform { get; init; }
|
||||||
public Matrix4x4 InverseWorldTransform { 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.
|
/// Pre-resolved polygon data with vertex positions and computed planes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
|
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
|
||||||
|
|
||||||
|
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
|
||||||
|
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
|
||||||
|
/// (collision) and from the renderer's drawing-BSP.
|
||||||
|
/// Source: <c>cellStruct.CellBSP</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>();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,4 +166,59 @@ public static class PhysicsDiagnostics
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool DumpSteepRoofEnabled { get; set; } =
|
public static bool DumpSteepRoofEnabled { get; set; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,20 +230,43 @@ public sealed class PhysicsEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolve the outdoor cell id that owns a world-space position.
|
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
|
||||||
/// Indoor ids are preserved because EnvCell ownership still comes from
|
/// given world position via retail's portal-graph traversal for indoor
|
||||||
/// portal/cell BSP state; outdoor ids are derived from the registered
|
/// cells, or via terrain grid lookup for outdoor cells.
|
||||||
/// landblock that currently contains the point.
|
///
|
||||||
|
/// <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>
|
/// </summary>
|
||||||
internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId)
|
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
|
||||||
{
|
{
|
||||||
if (fallbackCellId == 0)
|
if (fallbackCellId == 0) return 0;
|
||||||
return 0;
|
|
||||||
|
|
||||||
uint fallbackLow = fallbackCellId & 0xFFFFu;
|
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)
|
foreach (var kvp in _landblocks)
|
||||||
{
|
{
|
||||||
var lb = kvp.Value;
|
var lb = kvp.Value;
|
||||||
|
|
@ -252,9 +275,29 @@ public sealed class PhysicsEngine
|
||||||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||||||
{
|
{
|
||||||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||||||
return (fallbackCellId & 0xFFFF0000u) == 0
|
uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||||||
? lowCellId
|
|
||||||
: (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(
|
return new ResolveResult(
|
||||||
sp.CheckPos,
|
sp.CheckPos,
|
||||||
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
|
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
|
||||||
onGround,
|
onGround,
|
||||||
collisionNormalValid,
|
collisionNormalValid,
|
||||||
collisionNormal);
|
collisionNormal);
|
||||||
|
|
@ -744,7 +787,7 @@ public sealed class PhysicsEngine
|
||||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||||
return new ResolveResult(
|
return new ResolveResult(
|
||||||
sp.CheckPos,
|
sp.CheckPos,
|
||||||
ResolveOutdoorCellId(sp.CheckPos, partialCellId),
|
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
|
||||||
partialOnGround,
|
partialOnGround,
|
||||||
collisionNormalValid,
|
collisionNormalValid,
|
||||||
collisionNormal);
|
collisionNormal);
|
||||||
|
|
|
||||||
45
src/AcDream.Core/Physics/PortalInfo.cs
Normal file
45
src/AcDream.Core/Physics/PortalInfo.cs
Normal 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 & 2) == 0</c> → portal's polygon normal points INTO
|
||||||
|
/// the owning cell (so dist > 0 in cell-local space means "outside
|
||||||
|
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
|
||||||
|
/// load-hint path for unloaded neighbours.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct PortalInfo
|
||||||
|
{
|
||||||
|
public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
|
||||||
|
{
|
||||||
|
OtherCellId = otherCellId;
|
||||||
|
PolygonId = polygonId;
|
||||||
|
Flags = flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort OtherCellId { get; }
|
||||||
|
public ushort PolygonId { get; }
|
||||||
|
public ushort Flags { get; }
|
||||||
|
|
||||||
|
/// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
|
||||||
|
public bool PortalSide => (Flags & 2) == 0;
|
||||||
|
}
|
||||||
|
|
@ -1166,6 +1166,92 @@ public sealed class Transition
|
||||||
// Environment collision — outdoor terrain
|
// 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 >= 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>
|
/// <summary>
|
||||||
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
||||||
/// Indoor BSP collision is deferred to Task 6c.
|
/// Indoor BSP collision is deferred to Task 6c.
|
||||||
|
|
@ -1178,13 +1264,13 @@ public sealed class Transition
|
||||||
var sp = SpherePath;
|
var sp = SpherePath;
|
||||||
var ci = CollisionInfo;
|
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;
|
Vector3 footCenter = sp.GlobalSphere[0].Origin;
|
||||||
float sphereRadius = sp.GlobalSphere[0].Radius;
|
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 ────────────────────────────────────
|
// ── Indoor cell BSP collision ────────────────────────────────────
|
||||||
// If the player is in an indoor cell (low 16 bits >= 0x0100),
|
// If the player is in an indoor cell (low 16 bits >= 0x0100),
|
||||||
// query the CellStruct's PhysicsBSP for wall/floor/ceiling collision.
|
// 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 the full 6-path BSP dispatcher for retail-faithful collision.
|
||||||
// Use pre-resolved polygons (vertices+planes computed at cache time).
|
// Use pre-resolved polygons (vertices+planes computed at cache time).
|
||||||
var cellState = BSPQuery.FindCollisions(
|
var cellState = BSPQuery.FindCollisions(
|
||||||
|
|
@ -1231,12 +1323,57 @@ public sealed class Transition
|
||||||
Quaternion.Identity,
|
Quaternion.Identity,
|
||||||
engine); // engine needed for Path 5 step-up
|
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 (cellState != TransitionState.OK)
|
||||||
{
|
{
|
||||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
||||||
ci.CollidedWithEnvironment = true;
|
ci.CollidedWithEnvironment = true;
|
||||||
return cellState;
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal file
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -91,13 +91,20 @@ public static class WorldPicker
|
||||||
uint skipServerGuid,
|
uint skipServerGuid,
|
||||||
float maxDistance = 50f,
|
float maxDistance = 50f,
|
||||||
Func<uint, float>? radiusForGuid = null,
|
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 DefaultRadius = 1.0f;
|
||||||
const float DefaultVerticalOffset = 0.9f;
|
const float DefaultVerticalOffset = 0.9f;
|
||||||
|
|
||||||
if (direction.LengthSquared() < 1e-10f) return null;
|
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;
|
uint? bestGuid = null;
|
||||||
float bestT = float.PositiveInfinity;
|
float bestT = float.PositiveInfinity;
|
||||||
foreach (var entity in candidates)
|
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) t = -b + sqrtD; // origin inside sphere -> use far exit
|
||||||
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
||||||
if (t >= maxDistance) continue;
|
if (t >= maxDistance) continue;
|
||||||
|
if (t >= wallT) continue; // wall is between camera and entity (#86)
|
||||||
if (t < bestT)
|
if (t < bestT)
|
||||||
{
|
{
|
||||||
bestT = t;
|
bestT = t;
|
||||||
|
|
@ -207,11 +215,39 @@ public static class WorldPicker
|
||||||
IEnumerable<WorldEntity> candidates,
|
IEnumerable<WorldEntity> candidates,
|
||||||
uint skipServerGuid,
|
uint skipServerGuid,
|
||||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||||
float inflatePixels = 8f)
|
float inflatePixels = 8f,
|
||||||
|
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||||
{
|
{
|
||||||
uint? bestGuid = null;
|
uint? bestGuid = null;
|
||||||
float bestDepth = float.PositiveInfinity;
|
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)
|
foreach (var entity in candidates)
|
||||||
{
|
{
|
||||||
if (entity.ServerGuid == 0u) continue;
|
if (entity.ServerGuid == 0u) continue;
|
||||||
|
|
@ -237,6 +273,8 @@ public static class WorldPicker
|
||||||
if (mouseX < minX || mouseX > maxX) continue;
|
if (mouseX < minX || mouseX > maxX) continue;
|
||||||
if (mouseY < minY || mouseY > maxY) continue;
|
if (mouseY < minY || mouseY > maxY) continue;
|
||||||
|
|
||||||
|
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
|
||||||
|
|
||||||
if (depth < bestDepth)
|
if (depth < bestDepth)
|
||||||
{
|
{
|
||||||
bestDepth = depth;
|
bestDepth = depth;
|
||||||
|
|
|
||||||
|
|
@ -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: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform;
|
||||||
if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull;
|
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();
|
r.Spacing();
|
||||||
|
|
||||||
// Cycle / toggle actions live on the VM as Action handles; the
|
// Cycle / toggle actions live on the VM as Action handles; the
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,20 @@ public sealed class DebugVM
|
||||||
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
|
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>
|
/// <summary>
|
||||||
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
|
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
|
||||||
/// five indoor probes together. No dedicated env var; set any individual
|
/// five indoor probes together. No dedicated env var; set any individual
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
240
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
240
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -207,7 +207,10 @@ public class PhysicsEngineTests
|
||||||
|
|
||||||
Assert.True(result.IsOnGround);
|
Assert.True(result.IsOnGround);
|
||||||
Assert.InRange(result.Position.X, 24.9f, 25.1f);
|
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]
|
[Fact]
|
||||||
|
|
@ -228,7 +231,10 @@ public class PhysicsEngineTests
|
||||||
|
|
||||||
Assert.True(result.IsOnGround);
|
Assert.True(result.IsOnGround);
|
||||||
Assert.InRange(result.Position.X, 97.9f, 98.1f);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
35
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs
Normal file
35
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs
Normal file
44
tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
using AcDream.UI.Abstractions.Panels.Debug;
|
using AcDream.UI.Abstractions.Panels.Debug;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
|
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
|
||||||
|
|
@ -285,4 +286,26 @@ public sealed class DebugVMTests
|
||||||
Assert.Equal(1, weatherHits);
|
Assert.Equal(1, weatherHits);
|
||||||
Assert.Equal(1, wireHits);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue