docs: UCG W2 (one membership) spec + plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 10:14:06 +02:00
parent 07e68e0aff
commit 83c452b87f
2 changed files with 333 additions and 0 deletions

View file

@ -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 23 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) <noreply@anthropic.com>`.
---
## 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 <resolvedId>;` 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<ushort, SWVertex>() },
Polygons = new Dictionary<ushort, Polygon>(),
PhysicsBSP = null,
};
var dat = new DatEnvCell {
Flags = (DatReaderWriter.Enums.EnvCellFlags)0,
CellPortals = new List<DatReaderWriter.Types.CellPortal>(),
VisibleCells = new List<ushort>(),
};
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
/// <summary>
/// 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).
/// </summary>
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(<id>);` (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
/// <summary>
/// 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
/// <see cref="ComputeVisibility(Vector3)"/> when <paramref name="root"/> is null (e.g. the
/// cell isn't registered with this renderer yet) — so this never regresses below baseline.
/// </summary>
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 <test>
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 12) + §3 W2b (Task 3) + §5 acceptance (Task 4 visual gates). ✓
- **Placeholders:** Tasks 23 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 23 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.

View file

@ -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 23 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 12) 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.