acdream/docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md
Erik 840c1b6442 docs(render): Phase W (rev) — 4-model research + transition-membership/PView design
Four independent decomp studies (Opus 4.8 x2, Sonnet 4.6, external Codex)
converge: retail carries the cell through the collision sweep (validate_transition
advances curr_cell only on an accepted move, reverts on a block) and commits it in
SetPositionInternal — it never re-derives membership from a static resting position.
acdream already ports the sweep machinery (sp.CurCellId/CheckCellId, ValidateTransition,
CheckOtherCells) but ResolveWithTransition discards the swept cell and re-derives
statically via ResolveCellId (PhysicsEngine.cs:909/928) — the root of the
0170<->0031 doorway/cellar ping-pong. The do_not_load_cells prune is secondary
(static/cross-cell lists), not the anti-flicker; W2b was doubly misplaced and is reverted.

Render: one PView::ConstructView portal traversal over the same cell graph, rooted at
the physics current cell; seen_outside (not a dungeon flag) gates landscape; the outside
draws through exit portals clipped to the doorway (no blue-hole, no stencil split).
Dungeons/interiors share the machinery; "underground" is emergent.

Design doc lays out the staged, evidence-first rewrite (Stage 0 diagnostic ->
Stage 1 transition-owned membership [visual gate] -> Stage 2 CELLARRAY/prune parity ->
Stages 3-5 render root + PView seal + entity clip). Adds the shared research prompt and
all four study reports as the grounding record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:58:51 +02:00

165 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase W (rev) — Transition-owned cell membership + PView render pass
**Status:** design approved 2026-06-02 (user: "go ahead … thorough rewrite"). Supersedes the
W2b approach (stab-list prune inside the static `ResolveCellId`) and confirms the A8 two-pipe
abandonment. Behavior-changing across physics + render → visual verification at the Holtburg
cottage + a dungeon is the acceptance gate.
**Grounding:** four independent decomp studies (2026-06-02) — Opus 4.8 ×2, Sonnet 4.6, and an
external Codex pass — converge with no material contradiction. Reports:
`docs/research/2026-06-02-retail-cell-render-study-{opus48-a,opus48-b,sonnet46,codex}.md`;
shared brief: `docs/research/2026-06-02-retail-cell-render-research-prompt.md`. Verified against
live acdream code this session.
---
## 1. Root cause (verified, unanimous)
Retail never re-derives the player's cell from a static resting position. It **carries the cell
through the collision sweep** and commits it afterward:
- `CTransition::validate_transition` @ `0x0050aa70` (pseudo_c:272547-272619): advances
`sphere_path.curr_cell = check_cell` **only on an accepted, moved sub-step** (`:272608-272619`);
on a block/slide it **restores** `check_cell ← curr_cell` and returns OK (`:272593`). A
push-back or standing still therefore **cannot** change the cell.
- `check_cell` is supplied during `CTransition::check_other_cells` @ `0x0050ae50`
(pseudo_c:272717) → `CObjCell::find_cell_list` @ `0x0052b4e0` (pseudo_c:308742), which builds
the candidate `CELLARRAY` (current + portal/transit neighbors + outside cells), picks the
first **interior** cell whose `point_in_cell` contains the sphere center, and applies the
`do_not_load_cells` prune (`:308829-308867`).
- `CPhysicsObj::SetPositionInternal(CTransition*)` @ `0x00515330` (pseudo_c:283399-283462)
reads `sphere_path.curr_cell` and calls `change_cell` @ `0x00513390` **only when it differs**.
**acdream already ports the sweep machinery faithfully**`SpherePath.CurCellId`/`CheckCellId`
exist; `Transition.ValidateTransition` sets `sp.CurCellId = sp.CheckCellId` on accept and
reverts on block (`TransitionTypes.cs:3404-3434`); `CheckOtherCells` retargets `CheckCellId`
after a successful other-cell query (`TransitionTypes.cs:2061-2075`, `:1949`). **The defect is
the consumer:** `PhysicsEngine.ResolveWithTransition` discards the swept `sp.CurCellId` and
re-derives the cell from the static sphere origin via `ResolveCellId(sp.GlobalSphere[0].Origin,
…)` on both the OK and partial paths (`PhysicsEngine.cs:909` / `:928`). As the collision
push-back jitters the origin ±~8 cm across a boundary, that static re-derive flips — the entire
`0170↔0031` (and `0170↔0171`, cellar) ping-pong.
**Corollary (Codex):** the `do_not_load_cells` prune is a **secondary** parity/stability feature
for static/cross-cell lists (`calc_cross_cells_static` @ `0x00515160`), **not** the per-tick
anti-flicker mechanism. The load-bearing invariant is transition-owned membership. So the
membership-return fix alone is expected to stop the flicker; the prune is correctness we add
after. (This makes the shipped W2b doubly wrong — wrong location *and* wrong mechanism — and it
is reverted.)
## 2. Target architecture (retail-faithful)
**One cell graph, one membership authority, render obeys it.**
1. **Membership is transition-owned.** `ResolveWithTransition` returns the swept `sp.CurCellId`
(mirroring `SetPositionInternal`), not a post-sweep static re-derive. `ResolveCellId` is
**demoted** to the seed-only cases that have no transition: spawn / teleport / server cell
set. The render reads this single membership answer (`CellGraph.CurrCell`).
2. **`CellTransit` gains `CELLARRAY` parity:** `AddedOutside` / `DoNotLoadCells`, candidate
list, and the `find_cell_list` `do_not_load_cells` prune + interior-wins pick. Applied when
building no-load/static lists from an EnvCell origin. (Correctness/parity — not the flicker
fix.) The `FindTransitCellsSphere` unconditional `exitOutside=true` (`CellTransit.cs:95-123`,
A6.P5) is re-gated to retail's portal-plane/sphere test (it currently over-admits outside).
3. **Render roots at the physics current cell + `seen_outside`**, not an independent AABB
`FindCameraCell`. Mirrors `CellManager::ChangePosition` @ `0x004559b0` (pseudo_c:94601-94682):
landscape/sunlight/outdoor-ambient stay live iff the current cell is a landcell OR
`CObjCell::seen_outside` (acclient.h:30929) is set; otherwise landscape is released and
indoor ambient is used. The 3rd-person camera offset uses a graph/BSP child lookup
(`CEnvCell::find_visible_child_cell` @ `0x0052dc50`), rooted at the **player** cell (preserves
the U.4c flap fix), never an AABB reclassification.
4. **One PView portal traversal** replaces the indoor/outdoor split + stencil pipeline. Port
`PView::ConstructView` (`0x005a57b0` EnvCell / `0x005a59a0` CBldPortal), `InitCell`
(`0x005a4b70`), `AddToCell` (`0x005a4d90`), `AddViewToPortals` (`0x005a52d0`), `DrawPortal`
(`0x005a5ab0`), `DrawCells` (`0x005a4840`). It yields a `cell_draw_list` + a single
`OutsideView` from one BFS. Convergence is governed by per-cell `portal_view_type.update_count`
watermarks + a todo list (NOT a fixed cap — this replaces `PortalVisibilityBuilder
.MaxReprocessPerCell = 4` and closes #102). When `OutsideView.view_count > 0` (an exit portal
was traversed), `DrawCells` draws `LScape` (terrain/sky/rain) **clipped to the doorway** with
a conditional depth clear — so the outside is visible through the door and there is **no blue
clear-color hole**, by construction. No separate inside/outside stencil pass (WorldBuilder's
`RenderInsideOut` and ACViewer's brute-force are reference-divergent; the decomp wins).
5. **Entities/particles clip to the visible cell set** (`find_visible_child_cell` + the PView
draw list), not the world frustum — kills NPC/door/smoke bleed-through.
**Dungeons/underground are emergent, not flagged.** Indoors = current cell is an EnvCell
(`id & 0xFFFF ≥ 0x100`). "Underground" = an EnvCell with no `seen_outside` and no exit-portal
reachability, on a landblock with no meaningful terrain. There is no client `underground`
boolean. Pure-dungeon landblocks (all terrain heights 0, EnvCells, no buildings — ACE's
`IsDungeon` heuristic) simply have no landscape to draw; building interiors/cellars with
`seen_outside` still draw the clipped outdoor view through portals. Same transition + same PView
machinery for both.
## 3. Staged plan (evidence-first, lowest-risk first; each behavior-changing stage is visual-gated)
- **Stage 0 — Diagnostic (zero behavior change).** Add a probe logging, per
`ResolveWithTransition`, the swept `sp.CurCellId`/`CheckCellId` **alongside** the value the
static `ResolveCellId` would return. Launch, walk the doorway/cellar. **Prove the swept cell is
stable where the static one strobes.** If the swept cell is *also* tainted (the `exitOutside`
over-admission), do Stage 2's `FindTransitCellsSphere` re-gating first. Gate: evidence in hand.
- **Stage 1 — Transition-owned membership (behavior-changing).** Return `sp.CurCellId` from
`ResolveWithTransition`; demote the static `ResolveCellId` to the spawn/teleport/server-set
seed path only. Revert W2b (`2acd8f9`: `DoorwayHoldMargin` + the prune-in-`ResolveCellId`).
**Visual gate: the `0170↔0031` / `0170↔0171` / cellar strobe stops; collision unchanged.**
- **Stage 2 — `CELLARRAY` parity + prune (correctness).** Port `AddedOutside`/`DoNotLoadCells` +
the `do_not_load_cells` prune + interior-wins pick into `CellTransit`; re-gate the `exitOutside`
over-admission. Conformance tests against cottage/cellar/dungeon golden candidate sets.
- **Stage 3 — Render root unification (behavior-changing).** Root render visibility at
`CellGraph.CurrCell` + `seen_outside`; remove the `ACDREAM_A8_INDOOR_BRANCH` split, the AABB
`FindCameraCell` + grace-frame hack, and `RenderInsideOutAcdream` stencil path. Camera offset
via child-cell lookup. **Visual gate: no render-branch strobe; landscape policy correct
indoors/outdoors/dungeon.**
- **Stage 4 — PView traversal + seamless seal (behavior-changing, the big one).** Implement the
PView BFS (`update_count` watermark) producing `cell_draw_list` + `OutsideView`; draw landscape
through the doorway; cap ceilings; fold in the `EnvCellRenderer` inherited-`GL_BLEND` fix
(memory `render-self-contained-gl-state`). **Visual gate: interior sealed, outside visible
through the door (sky/rain), no blue-hole, no transparent walls.**
- **Stage 5 — Entity/particle cell clipping (behavior-changing).** Clip entities/particles to the
PView visible set. **Visual gate: no NPC/door/smoke bleed-through; entities through a doorway
remain visible.**
## 4. Must-port / align (decomp addresses)
| Area | Functions |
|---|---|
| Membership commit | `validate_transition 0x0050aa70`, `SetPositionInternal 0x00515330`, `change_cell 0x00513390` |
| Cell array | `find_cell_list 0x0052b4e0`, `CEnvCell::find_transit_cells 0x0052c820`, `CLandCell::add_all_outside_cells 0x00533630`, `CELLARRAY::add_cell 0x006b4ff0`, `remove_cell 0x006b4e80`, `calc_cross_cells_static 0x00515160` |
| Render root | `CellManager::ChangePosition 0x004559b0`, `SmartBox::is_player_outside 0x00451e80`, `Position::get_outside_cell_id 0x004527b0`, `find_visible_child_cell 0x0052dc50` |
| PView | `ConstructView 0x005a57b0`/`0x005a59a0`, `InitCell 0x005a4b70`, `AddToCell 0x005a4d90`, `AddViewToPortals 0x005a52d0`, `DrawPortal 0x005a5ab0`, `DrawCells 0x005a4840` |
| Structs (acclient.h) | `SPHEREPATH:32625`, `CObjCell:30915`, `CEnvCell:32072`, `CLandCell:31886`, `CCellPortal:32300`, `CBldPortal:32094`, `CELLARRAY:31574`, `portal_view_type:32346`, `seen_outside @ 30929` |
Cross-checks: ACE `Transition.cs:984`, `PhysicsObj.cs:1171`, `ObjCell.cs:335`, `EnvCell.cs:311`,
`CellArray.cs:17`; ACViewer render; WorldBuilder `PortalService`/`VisibilityManager` (reference
base for the Silk.NET implementation, NOT the algorithm).
## 5. Risks
- **Verify-before-delete (Codex):** prove the swept cell is correct before removing the static
re-derive (Stage 0). Keep `ResolveCellId` for the seed-only path.
- **`exitOutside` over-admission** (`CellTransit.cs:95-123`): could taint the swept cell; re-gate
in Stage 2 (or Stage 0 if Stage-0 evidence shows taint).
- **Collision regression (#98 area):** Stage 1 must NOT touch collision math (only the returned
cell id). Lean on the existing #98 fixtures + cottage-floor-cap regression test.
- **Render regression:** build the PView traversal as a frame *product* and diff it against the
current `PortalVisibilityBuilder` output before changing draw order (Stage 4).
- **Dungeon PVS blowup (#95):** watch the `update_count` convergence on dungeon graphs; the
watermark/todo model is the retail fix but validate on a real dungeon.
- **Dungeon vs cottage QA divergence:** pure dungeons draw no terrain; `seen_outside` cellars do.
## 6. Conformance / acceptance
- Unit/replay: doorway-stationary "zero cell changes" test at `0xA9B40170↔0xA9B40031`;
room↔vestibule and cellar-ramp-top cell-change-only-on-accepted-move tests; blocked-wall
no-cell-change test; golden `CELLARRAY` candidate sets (cottage door, cellar exit, interior
portal, building entry); PView convergence test (a cell receiving multiple clipped slices);
doorway `OutsideView` non-empty + sealed-cellar `OutsideView`-empty tests.
- Visual (decisive, the user): cottage — flicker gone (Stage 1), interior sealed with sky/rain
through the door, no blue-hole, no transparent walls, no bleed-through (Stages 4-5); a real
dungeon — sealed, no terrain, traversal converges without blowup.
- `dotnet build` green; no NEW deterministic test failures vs the documented static-leak baseline.
## 7. Task decomposition → plan
Stages 0-5 above map to plan tasks. Stages 0-2 (membership + parity) are one coherent chunk with
a visual gate after Stage 1. Stages 3-5 (render) are the second chunk; Stage 4 (PView) likely
warrants its own detailed sub-spec written after the Stage-1 visual gate confirms the membership
foundation. W2a (`CellGraph.CurrCell` + `ComputeVisibilityFromRoot`) is kept and built upon; W2b
is reverted.