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>
This commit is contained in:
Erik 2026-06-02 13:58:51 +02:00
parent 2acd8f9e1d
commit 840c1b6442
6 changed files with 3608 additions and 0 deletions

View file

@ -0,0 +1,165 @@
# 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.