diff --git a/docs/superpowers/plans/2026-06-02-unified-cell-graph-stage2.md b/docs/superpowers/plans/2026-06-02-unified-cell-graph-stage2.md new file mode 100644 index 0000000..4048374 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-unified-cell-graph-stage2.md @@ -0,0 +1,196 @@ +# Unified Cell Graph — Stage 2 (W2: One Membership) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Make physics's `ResolveCellId` the single cell-membership answer (`CellGraph.CurrCell`), have render read it, and add retail `find_cell_list` stab-list doorway hysteresis — collapsing the dual render/physics resolvers. + +**Architecture:** Spec `docs/superpowers/specs/2026-06-02-unified-cell-graph-stage2-design.md`. W1's `CellGraph`/`ObjCell`/`EnvCell`/`LandCell` are in place. Task 1 is additive (write-only, zero-behavior-change). Tasks 2–3 are behavior-changing → **visual verification at the Holtburg cottage is the acceptance gate** (do not mark W2 done without it). + +**Tech Stack:** C# .NET 10, xUnit. Branch `claude/thirsty-goldberg-51bb9b` (unpushed — do NOT push). Every commit ends with `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +## File Structure +- Modify `src/AcDream.Core/Physics/PhysicsEngine.cs` — Task 1 (write `CurrCell`) + Task 3 (hysteresis). +- Modify `src/AcDream.App/Rendering/CellVisibility.cs` + `src/AcDream.App/Rendering/GameWindow.cs` — Task 2 (render reads `CurrCell`). +- Tests: `tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs` (T1, T3); `tests/AcDream.App.Tests/...` (T2, if an App test project pattern fits — else a Core-side test of the root-selection helper). + +--- + +## Task 1: `ResolveCellId` writes `CellGraph.CurrCell` (additive, zero-behavior-change) + +**Files:** Modify `src/AcDream.Core/Physics/PhysicsEngine.cs`; Test `tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs`. + +- [ ] **Step 1: Confirm the return sites.** Read `PhysicsEngine.ResolveCellId` (`PhysicsEngine.cs:259-372`). Identify every `return ;` that returns a *resolved* cell (indoor result ~:310/:328; outdoor result ~:367-368) versus the no-match fallback `return fallbackCellId;` (~:371) which must leave `CurrCell` UNCHANGED. Confirm `DataCache` is the `PhysicsDataCache?` property (`:45`) exposing `.CellGraph` (W1). + +- [ ] **Step 2: Write the failing test** at `tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using DatReaderWriter.Types; +using Xunit; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; + +namespace AcDream.Core.Tests.Physics; + +public class CellGraphMembershipTests +{ + [Fact] + public void ResolveCellId_IndoorResult_WritesCurrCellToTheEnvCell() + { + var engine = new PhysicsEngine(); + var cache = new PhysicsDataCache(); + engine.DataCache = cache; + + // Register an EnvCell in the graph for the id ResolveCellId will return. + // Use a minimal synthetic dat cell (no PhysicsBSP -> CacheCellStruct drops it from + // _cellStruct but the graph add runs first, per W1 Task 7). + var cs = new CellStruct { + VertexArray = new VertexArray { Vertices = new Dictionary() }, + Polygons = new Dictionary(), + PhysicsBSP = null, + }; + var dat = new DatEnvCell { + Flags = (DatReaderWriter.Enums.EnvCellFlags)0, + CellPortals = new List(), + VisibleCells = new List(), + }; + cache.CacheCellStruct(0xA9B40174u, dat, cs, Matrix4x4.Identity); + + // With no physics cell data for collision, ResolveCellId on a bare indoor fallback + // returns the fallback id (the indoor branch's early-return when no BSP confirms), + // which is the indoor cell — assert CurrCell tracks it. + engine.ResolveCellId(new Vector3(0,0,0), 0.5f, 0xA9B40174u); + + Assert.NotNull(cache.CellGraph.CurrCell); + Assert.Equal(0xA9B40174u, cache.CellGraph.CurrCell!.Id); + } +} +``` +> Step 1 may show `ResolveCellId` returns the fallback id under these minimal conditions (no loaded landblock/cell-struct). If the exact return differs, adjust the *setup* so the test exercises a resolved-id return and asserts `CurrCell.Id == thatId` — keep the assertion shape (CurrCell tracks the resolved id). If `PhysicsEngine`/`DataCache` can't be constructed this simply, report NEEDS_CONTEXT. + +- [ ] **Step 3: Run, verify FAIL:** `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphMembershipTests"` + +- [ ] **Step 4: Implement.** Add a private helper to `PhysicsEngine`: +```csharp + /// + /// UCG W2: record the resolved cell as the single membership answer. Additive — + /// CurrCell is written here and read by the render root (W2 Task 2). Leaves CurrCell + /// unchanged when the id can't be resolved in the graph (stale beats null). + /// + private uint SetCurrAndReturn(uint resolvedId) + { + if (DataCache?.CellGraph is { } cg && cg.GetVisible(resolvedId) is { } cell) + cg.CurrCell = cell; + return resolvedId; + } +``` +Wrap each *resolved-id* return in `ResolveCellId` as `return SetCurrAndReturn();` (the indoor-result returns + the outdoor-result return). Leave the no-match `return fallbackCellId;` (~:371) as-is (unchanged CurrCell). Do NOT alter any existing logic — only wrap the returns. + +- [ ] **Step 5: Run, verify PASS.** Same filter. Then full Core build green. + +- [ ] **Step 6: Confirm still zero-behavior-change.** `rg "CurrCell" src` shows only the W1 declaration + this write (no reader yet). Commit: +``` +git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs +git commit -m "feat(core): UCG W2 Task 1 — ResolveCellId writes CellGraph.CurrCell (additive)" +``` + +--- + +## Task 2: Render reads `CurrCell` (BEHAVIOR-CHANGING → visual gate) + +**Files:** Modify `src/AcDream.App/Rendering/CellVisibility.cs`, `src/AcDream.App/Rendering/GameWindow.cs`. + +- [ ] **Step 1: Confirm the render path.** Read `CellVisibility.ComputeVisibility`/`GetVisibleCells`/`FindCameraCell` (`CellVisibility.cs:327-413,488+`) and the call site `GameWindow.cs:7153-7157,7295`. Confirm: `TryGetCell(uint, out LoadedCell)` exists (`:286`); how `GameWindow` can reach the physics `CellGraph.CurrCell` (via `_physicsEngine.DataCache?.CellGraph` — confirm `_physicsEngine` field + `DataCache` accessibility). Report what you find before editing. + +- [ ] **Step 2: Add `ComputeVisibilityFromRoot`.** In `CellVisibility`, add an overload that runs the existing portal BFS from a supplied root cell, falling back to the position path when the root is null: +```csharp + /// + /// UCG W2: compute visibility from a supplied root cell (the physics membership answer), + /// instead of resolving the root from a position. Falls back to the position-based + /// when is null (e.g. the + /// cell isn't registered with this renderer yet) — so this never regresses below baseline. + /// + public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos) + { + if (root is null) return ComputeVisibility(fallbackPos); + _lastCameraCell = root; // seed the cache so the BFS roots here + LastVisibilityResult = GetVisibleCells(root, fallbackPos); // BFS from root (see Step 3) + return LastVisibilityResult; + } +``` +- [ ] **Step 3: Refactor `GetVisibleCells` to accept an explicit root.** Extract the BFS body into `GetVisibleCells(LoadedCell root, Vector3 viewerPos)` and have the existing `GetVisibleCells(Vector3)` call `FindCameraCell(pos)` then delegate to it (null root → null result, today's behavior). Keep the BFS/portal-clip logic byte-identical — only the root acquisition moves out. + +- [ ] **Step 4: Wire `GameWindow` to prefer the physics root.** At `GameWindow.cs:7153-7156`, select the root from `CellGraph.CurrCell`: +```csharp + var visRootPos = (_playerMode && _playerController is not null) ? _playerController.Position : camPos; + LoadedCell? physRoot = null; + if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell e + && _cellVisibility.TryGetCell(e.Id, out var lc)) + physRoot = lc; + var visibility = _cellVisibility.ComputeVisibilityFromRoot(physRoot, visRootPos); +``` +(Confirm the exact `_physicsEngine` accessor in Step 1; adapt if the field name differs.) + +- [ ] **Step 5: Test** the root-selection + fallback (in `AcDream.App.Tests` if that pattern exists; else a focused `CellVisibility` test): `ComputeVisibilityFromRoot(root, pos)` BFS-roots at `root`; `ComputeVisibilityFromRoot(null, pos)` equals `ComputeVisibility(pos)`. Build green; full Core+App build green. + +- [ ] **Step 6: Commit** (behavior-changing — note the visual gate is pending): +``` +git add src/AcDream.App/Rendering/CellVisibility.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(app): UCG W2 Task 2 — render root from physics CurrCell (FindCameraCell fallback)" +``` +- [ ] **Step 7: STOP for visual verification (W2a).** Launch per CLAUDE.md; spawn in the cellar + walk the cottage; confirm the indoor path engages on spawn (no world-from-below from a null root). Do not proceed to Task 3 until the user confirms W2a. + +--- + +## Task 3: Stab-list doorway hysteresis (BEHAVIOR-CHANGING → visual gate) + +**Files:** Modify `src/AcDream.Core/Physics/PhysicsEngine.cs`; Test in `CellGraphMembershipTests.cs`. + +- [ ] **Step 1: Confirm the fall-through point + read retail.** In `ResolveCellId`, find where the indoor branch falls through to outdoor (after the `:326-328` sphere-overlap check fails). Read retail `find_cell_list` `do_not_load_cells` prune (`docs/research/named-retail/acclient_2013_pseudo_c.txt:308829-308867`) to finalize the predicate. Confirm `CellGraph.CurrCell` is readable here (it's on `DataCache.CellGraph`) and `EnvCell.StabList` is the landblock-prefixed visible-cell list. + +- [ ] **Step 2: Write the failing test** — doorway hold: a foot-sphere position that `ResolveCellId` would classify outdoor (`outdoorCandidate`), with the previous `CurrCell` = an indoor `EnvCell` whose `StabList` does NOT contain `outdoorCandidate`, AND the sphere still within the prev cell's proximity ⇒ `ResolveCellId` returns the indoor cell id (held). A second case where `StabList` DOES contain the candidate (or the sphere has cleared proximity) ⇒ returns outdoor. Use #98 cottage fixtures (`0xA9B4014x`). (Write concrete arrange/act/assert; finalize the proximity predicate from Step 1.) + +- [ ] **Step 3: Run, verify FAIL.** + +- [ ] **Step 4: Implement the prune.** Before the indoor→outdoor fall-through in `ResolveCellId`: +```csharp + // UCG W2b — retail find_cell_list do_not_load_cells prune (pseudo_c:308829-308867): + // only accept a change to the outdoor candidate if it's reachable from the current + // cell's stab list; otherwise hold the indoor cell one more tick. Anti-ping-pong the + // #98 saga lacked. + if (DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell prev + && !prev.StabList.Contains(outdoorCandidate) + && /* stillNearPrev: sphere within prev's expanded local AABB */ ) + { + return SetCurrAndReturn(prev.Id); + } +``` +Finalize `stillNearPrev` (a containment/proximity test against `prev`'s bounds, expanded by the sphere radius) per Step 1. Keep it additive — it only adds a hold path before the existing fall-through. + +- [ ] **Step 5: Run, verify PASS.** Full Core build + suite (no NEW deterministic failures vs baseline). + +- [ ] **Step 6: Commit:** +``` +git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs +git commit -m "feat(core): UCG W2 Task 3 — stab-list doorway hysteresis in ResolveCellId" +``` +- [ ] **Step 7: STOP for visual verification (W2b).** Walk room ↔ cellar ↔ outside at the inn doorway; confirm `[cell-transit]` (ACDREAM_PROBE_CELL=1) stays stable (no ping-pong). User confirms before W2 is marked done. + +--- + +## Task 4: Verify + roadmap +- [ ] App build 0 errors; full Core suite no NEW deterministic failures vs the static-leak baseline. +- [ ] Both visual gates (Task 2 Step 7 + Task 3 Step 7) confirmed by the user. +- [ ] Flip W2 to shipped in `docs/plans/2026-04-11-roadmap.md` (the Phase W table). Commit. + +--- + +## Self-Review +- **Spec coverage:** §3 W2a (Tasks 1–2) + §3 W2b (Task 3) + §5 acceptance (Task 4 visual gates). ✓ +- **Placeholders:** Tasks 2–3 carry "confirm/finalize" steps (the render path + the hysteresis predicate need confirmation against live code + retail) — these are verify-then-use, with the concrete code supplied. Task 3's `stillNearPrev` predicate is the one genuinely-to-finalize item; flagged explicitly. +- **Zero-behavior-change boundary:** Task 1 is additive/write-only (CurrCell read by nobody until Task 2) — provable like a W1 task. Tasks 2–3 are behavior-changing and gated on visual verification. +- **Type consistency:** `CellGraph.CurrCell` (W1), `EnvCell.StabList`/`.Id` (W1), `CellVisibility.TryGetCell`/`ComputeVisibility` (existing), `PhysicsEngine.DataCache` (`:45`), `SetCurrAndReturn` (T1) used in T3. diff --git a/docs/superpowers/specs/2026-06-02-unified-cell-graph-stage2-design.md b/docs/superpowers/specs/2026-06-02-unified-cell-graph-stage2-design.md new file mode 100644 index 0000000..8beeb36 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-unified-cell-graph-stage2-design.md @@ -0,0 +1,137 @@ +# Unified Cell Graph (Phase W) — Stage 2 (W2): One Membership + +**Status:** design approved 2026-06-02. Implementation pending plan. +**Branch:** `claude/thirsty-goldberg-51bb9b` (unpushed). +**Predecessor:** W1 (ObjCell scaffold, shipped `9cb1571`→`f2663b7`). +**Grounding:** the Stage-2 research (in-session) + `docs/research/2026-06-02-render-cell-membership-evidence.md`. +**Behavior-changing:** YES (first such stage). Requires visual verification at the Holtburg cottage. + +--- + +## 1. Goal + +Make the player's cell membership a **single answer** that both physics and render obey, +and give the indoor↔outdoor seam the **retail doorway hysteresis** it has always lacked. +Today there are two independent resolvers that disagree (the proven root cause of the +indoor "world from below"): +- **Physics** — `PhysicsEngine.ResolveCellId` (BSP-based, `SphereIntersectsCellBsp`), feeds `PlayerMovementController.CellId`. +- **Render** — `CellVisibility.FindCameraCell` (AABB-based, cache→neighbour→bruteforce→grace→None), feeds `clipRoot`. + +They use the *same* position (player pos, post-flap-fix) but different containment tests +and different registries, so they diverge — on spawn-in (physics knows the cell instantly, +render fires zero probes) and on within-building flicker. + +W2 makes **physics's `ResolveCellId` the single source**, writes it into +`CellGraph.CurrCell`, and has render **read** that instead of re-resolving. Then it ports +retail `find_cell_list`'s **stab-list prune** as the anti-ping-pong the #98 saga never had. + +## 2. Current state (grounded, file:line) + +- `PhysicsEngine.ResolveCellId(worldPos, sphereRadius, fallbackCellId)` (`PhysicsEngine.cs:259-372`): + indoor branch (`fallbackLow >= 0x0100`) → `CellTransit.FindCellList` → if the resolved + cell's `CellBSP.Root` non-null, the **#90 sphere-overlap post-check** (`:326-328`, + `SphereIntersectsCellBsp`) keeps it indoor while the foot-sphere still overlaps, else + falls through to outdoor; outdoor branch → `ComputeOutdoorCellId` + `CheckBuildingTransit`. + Result → `ResolveResult.CellId` → `PlayerMovementController.UpdateCellId(.., "resolver")` (`:1296`). +- **The #90 sphere-overlap check (`:326-328`) is the ONLY active stickiness.** The A6.P3 + slice-3 attempt was reverted (`:271-289` is now just a comment). **`CellTransit` is + stateless per call** — no persistent `CellArray` like retail's `Transition.cell_array`, + and **no stab-list prune** (the retail anti-ping-pong is entirely absent). +- `CellVisibility.FindCameraCell` (`CellVisibility.cs:356-413`): AABB ladder + `_lastCameraCell` + + 3-frame grace. `ComputeVisibility(visRootPos)` (`:327`) wraps `GetVisibleCells` (BFS). + Called from `GameWindow.cs:7153-7156` with `visRootPos = player pos`; result → `clipRoot` (`:7295`). +- `CellGraph.CurrCell { get; internal set; }` exists (W1) and is inert. + +## 3. Design + +### W2a — unify the membership (Task 1 + Task 2) + +**Task 1 — `ResolveCellId` writes `CurrCell` (additive, zero-behavior-change).** +At each `ResolveCellId` return site, set `DataCache.CellGraph.CurrCell = CellGraph.GetVisible(resolvedId)`. +Encapsulate in a private `PhysicsEngine` helper. The no-landblock-match fallback (`:371`, +returns `fallbackCellId`) leaves `CurrCell` **unchanged** (stale beats null when we don't +know where the player is). `CurrCell` is still **read by nobody** after Task 1 — so Task 1 +is provably zero-behavior-change and lands like a W1 task (unit-tested, no visual gate). + +**Task 2 — render reads `CurrCell` (BEHAVIOR-CHANGING).** +`GameWindow`'s visibility path selects the root from physics's answer, with a graceful +fallback: +``` +root = (CurrCell is EnvCell e && _cellVisibility.TryGetCell(e.Id, out var lc)) ? lc + : FindCameraCell(visRootPos) // fallback: render's own resolution +``` +Implement as a new `CellVisibility.ComputeVisibilityFromRoot(LoadedCell? root, Vector3 visRootPos)` +that runs the existing portal BFS from the given root (or falls back to the position path +when `root` is null). The BFS / portal traversal is **unchanged** — only the root source +changes. The fallback preserves today's behavior whenever physics hasn't registered the +cell with the render registry yet (the spawn-in registration-timing gap), so Task 2 can +only *improve* on the divergence, never regress below today's baseline. + +This kills the spawn-in + null-root-flicker divergence: whenever physics has the indoor +cell AND it's registered with render, render uses the BSP-grounded answer and cannot +diverge to a null/AABB-miss root. + +### W2b — doorway hysteresis (Task 3, BEHAVIOR-CHANGING) + +Port retail `find_cell_list`'s stab-list prune into `ResolveCellId`'s indoor→outdoor +fall-through. Before falling through to the outdoor branch (after the `:326-328` +sphere-overlap check fails), consult the previous `CurrCell`: +``` +// Retail find_cell_list do_not_load_cells prune: a cell change to the outdoor candidate +// is only accepted if that candidate is reachable from the current cell's stab list. +if (prevCurr is EnvCell pe && !pe.StabList.Contains(outdoorCandidate) && stillNearPrev) + return prevCurr.Id; // hold the indoor cell one more tick +``` +`StabList` is already populated on `EnvCell` (W1, from `datCell.VisibleCells`). This is the +retail-faithful anti-ping-pong; every prior #98 attempt guessed at cap mechanics and +**never had the stab-list prune**. `stillNearPrev` is a small proximity guard (sphere +within the prev cell's expanded AABB) so the hold releases once the player has genuinely +left. Exact predicate to be finalized in the plan against the retail `:308829-308867` prune. + +## 4. What W2 does NOT do +- **Render draw gates / OutsideView clipping** (the full seamless seal) — **W3**. +- **Collision** query mechanics (`FindEnvCollisions`, `ShadowObjects`) — **W4**. +- **Streaming** / terrain-as-LandCell — **W5**. +- W2 changes only *which cell is the membership answer* and *who reads it* + the seam hysteresis. + +## 5. Acceptance (visual — the decisive gate) +- **Spawn in the cellar** → indoor render path engages immediately; no "world from below" + from a null/AABB-miss root. +- **Walk room ↔ cellar ↔ outside** → `[cell-transit]` stays stable at the inn doorway (no + ping-pong); render engaged throughout. +- **No regression** to outdoor walking or existing collision behavior. + +## 6. Tests +- **Task 1:** after `ResolveCellId` resolves an indoor cell (registered in the graph), + `CellGraph.CurrCell` is that `EnvCell`; an outdoor resolution sets a `LandCell` (or leaves + it unchanged on no-match). Unit test via `PhysicsEngine` + a registered fixture cell. +- **Task 2:** `ComputeVisibilityFromRoot(root, pos)` returns the BFS from `root` when given; + falls back to the `FindCameraCell(pos)` path when `root == null`. Unit/integration test in + `AcDream.App.Tests`. +- **Task 3:** doorway-hysteresis unit test — foot-sphere just past the indoor BSP with an + outdoor candidate NOT in the prev cell's `StabList` ⇒ `ResolveCellId` returns the indoor + cell (held); once the sphere clears `stillNearPrev` ⇒ it releases to outdoor. Use the #98 + cottage fixtures. + +## 7. Acceptance criteria +- `dotnet build` green; new tests green; existing suite no NEW deterministic failures + (vs the documented static-leak flaky baseline). +- Task 1 verified zero-behavior-change (CurrCell write-only until Task 2). +- Tasks 2–3 visual-verified at the cottage (the user's eyes) before W2 is marked done. + +## 8. Risks +- **This is the #98 ping-pong area** (~10 prior failed attempts). The stab-list prune is the + retail-faithful approach none of them tried, so it's a genuine shot — but the visual gate + is decisive. W2a (Tasks 1–2) stands on its own (kills spawn-in/flicker) even if W2b needs + iteration. +- **Registration-timing gap:** render can only read `CurrCell` for cells in its `_cellLookup`. + The Task-2 fallback to `FindCameraCell` keeps behavior at today's baseline when the cell + isn't registered yet; fully closing that gap (if it persists) is a W2 follow-up or W3. +- **Thread:** `CurrCell` is written from the game thread (`ResolveWithTransition`) and read + from the game thread (`OnRender`) — no threading issue while both stay on the main thread. + +## 9. Task decomposition (→ plan) +1. `ResolveCellId` writes `CurrCell` (additive, unit-tested, no visual gate). +2. Render reads `CurrCell` with `FindCameraCell` fallback (behavior-changing → visual gate). +3. Stab-list doorway hysteresis in `ResolveCellId` (behavior-changing → visual gate). +4. Verify + visual-verify at the cottage.