docs: UCG W2 (one membership) spec + plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
07e68e0aff
commit
83c452b87f
2 changed files with 333 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue