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