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