acdream/docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md
Erik ed00719cf4 docs(render): Phase W §1a — Stage-1 gate finding (deeper root: FindEnvCollisions:1947)
Stage 1 (return swept sp.CurCellId, 3e1d502) was gated and the doorway strobe
PERSISTS: [cell-transit] still flips 0170<->0031. Airtight root from code analysis:
Transition.FindEnvCollisions re-derives the cell from the STATIC origin via
engine.ResolveCellId at TransitionTypes.cs:1947 and clobbers sp.CheckCellId (:1949)
at the start of every sweep pass — a second, earlier static re-derive the four
studies missed (they targeted the late return-site). It is the sole path that can
set an indoor swept cell outdoor (the containment pick at :2075 skips outdoor cells).
:1947 is dual-purpose (jitter source AND the only indoor->outdoor exit), so Stage 2
must replace it with a directed exit-portal crossing + do_not_load prune + exitOutside
re-gate — a careful #98-area rework, not a one-line delete. Render residuals at the
gate (no interior outside-looking-in, blue-through-door, particle/NPC bleed) are all
expected Stages 3-5, not Stage-1 regressions. Stage 1 is kept (correct + necessary).

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

198 lines
15 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.)
## 1a. Stage-1 gate finding (2026-06-02) — the deeper root is `FindEnvCollisions:1947`
Stage 1 (return `sp.CurCellId`) shipped (`3e1d502`) and was visually gated. **The doorway strobe
PERSISTS:** `[cell-transit]` (now fed by `sp.CurCellId`) still flips `0170↔0031` at a ~0.2 m
jitter. So the swept cell is itself tainted — Codex's "verify before delete" caution was correct.
**Airtight root (code analysis):** `Transition.FindEnvCollisions` re-derives the cell from the
**static** sphere origin via `engine.ResolveCellId(sp.GlobalSphere[0].Origin, …)` at
`TransitionTypes.cs:1947` at the *start* of every sweep pass and overwrites `sp.CheckCellId`
(`:1949`). `ValidateTransition` then latches that into `sp.CurCellId`. This is a **second, earlier
static re-derive** the four studies under-analyzed (they all targeted the late return-site). It is
the **only** path that can set an indoor swept cell to outdoor: the containment pick
(`FindCellSet`/`BuildCellSetAndPickContaining` at `:2075`) skips outdoor landcells (no `CellBSP`),
so `:1947→:1949` is the sole outdoor-taint source. Stage 1's return fix was necessary but
insufficient; `:1947` is the load-bearing taint.
**Why it's not a one-line delete:** `:1947` is dual-purpose — it is *both* the jitter source *and*
the only indoor→outdoor exit mechanism acdream has (naive removal fixes the strobe but strands the
player indoors, unable to walk out). Stage 2 must **replace** it with a retail-faithful directed
exit-portal crossing (`CEnvCell::find_transit_cells` exit-portal path — become outdoor by crossing
the doorway polygon, not by a static re-resolve) plus the `do_not_load_cells` prune for indoor
candidate stability, and re-gate `CellTransit.FindTransitCellsSphere`'s unconditional
`exitOutside=true` (`CellTransit.cs:95-123`). This is the genuine transition-cell-tracking rework;
it is `#98`-area (FindEnvCollisions/CheckOtherCells) and must be approached carefully, not guessed.
**Render residuals seen at the Stage-1 gate (all expected; Stages 3-5):** outside-looking-in shows
no interior (walls/floor missing, cellar mouth shows ground) = no PView exterior→interior portal
(U.5); bluish background + all particles/NPCs at the threshold/stairs = ungated entities + the
strobe-induced render-root flip (Stage 2 stabilizes the flip; Stage 4 seals); sky-through-door is
blue/no-clouds and down/up portals show blue = `OutsideView`/portal not drawn (Stage 4); cellar
renders best (walls+roof) with a blue flicker over the exit = closest to sealed, residual portal.
None of these are Stage-1 regressions.
## 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.