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

15 KiB
Raw Blame History

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