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>
15 KiB
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): advancessphere_path.curr_cell = check_cellonly on an accepted, moved sub-step (:272608-272619); on a block/slide it restorescheck_cell ← curr_celland returns OK (:272593). A push-back or standing still therefore cannot change the cell.check_cellis supplied duringCTransition::check_other_cells@0x0050ae50(pseudo_c:272717) →CObjCell::find_cell_list@0x0052b4e0(pseudo_c:308742), which builds the candidateCELLARRAY(current + portal/transit neighbors + outside cells), picks the first interior cell whosepoint_in_cellcontains the sphere center, and applies thedo_not_load_cellsprune (:308829-308867).CPhysicsObj::SetPositionInternal(CTransition*)@0x00515330(pseudo_c:283399-283462) readssphere_path.curr_celland callschange_cell@0x00513390only 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.
- Membership is transition-owned.
ResolveWithTransitionreturns the sweptsp.CurCellId(mirroringSetPositionInternal), not a post-sweep static re-derive.ResolveCellIdis demoted to the seed-only cases that have no transition: spawn / teleport / server cell set. The render reads this single membership answer (CellGraph.CurrCell). CellTransitgainsCELLARRAYparity:AddedOutside/DoNotLoadCells, candidate list, and thefind_cell_listdo_not_load_cellsprune + interior-wins pick. Applied when building no-load/static lists from an EnvCell origin. (Correctness/parity — not the flicker fix.) TheFindTransitCellsSphereunconditionalexitOutside=true(CellTransit.cs:95-123, A6.P5) is re-gated to retail's portal-plane/sphere test (it currently over-admits outside).- Render roots at the physics current cell +
seen_outside, not an independent AABBFindCameraCell. MirrorsCellManager::ChangePosition@0x004559b0(pseudo_c:94601-94682): landscape/sunlight/outdoor-ambient stay live iff the current cell is a landcell ORCObjCell::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. - One PView portal traversal replaces the indoor/outdoor split + stencil pipeline. Port
PView::ConstructView(0x005a57b0EnvCell /0x005a59a0CBldPortal),InitCell(0x005a4b70),AddToCell(0x005a4d90),AddViewToPortals(0x005a52d0),DrawPortal(0x005a5ab0),DrawCells(0x005a4840). It yields acell_draw_list+ a singleOutsideViewfrom one BFS. Convergence is governed by per-cellportal_view_type.update_countwatermarks + a todo list (NOT a fixed cap — this replacesPortalVisibilityBuilder .MaxReprocessPerCell = 4and closes #102). WhenOutsideView.view_count > 0(an exit portal was traversed),DrawCellsdrawsLScape(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'sRenderInsideOutand ACViewer's brute-force are reference-divergent; the decomp wins). - 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 sweptsp.CurCellId/CheckCellIdalongside the value the staticResolveCellIdwould return. Launch, walk the doorway/cellar. Prove the swept cell is stable where the static one strobes. If the swept cell is also tainted (theexitOutsideover-admission), do Stage 2'sFindTransitCellsSpherere-gating first. Gate: evidence in hand. - Stage 1 — Transition-owned membership (behavior-changing). Return
sp.CurCellIdfromResolveWithTransition; demote the staticResolveCellIdto the spawn/teleport/server-set seed path only. Revert W2b (2acd8f9:DoorwayHoldMargin+ the prune-in-ResolveCellId). Visual gate: the0170↔0031/0170↔0171/ cellar strobe stops; collision unchanged. - Stage 2 —
CELLARRAYparity + prune (correctness). PortAddedOutside/DoNotLoadCells+ thedo_not_load_cellsprune + interior-wins pick intoCellTransit; re-gate theexitOutsideover-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 theACDREAM_A8_INDOOR_BRANCHsplit, the AABBFindCameraCell+ grace-frame hack, andRenderInsideOutAcdreamstencil 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_countwatermark) producingcell_draw_list+OutsideView; draw landscape through the doorway; cap ceilings; fold in theEnvCellRendererinherited-GL_BLENDfix (memoryrender-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
ResolveCellIdfor the seed-only path. exitOutsideover-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
PortalVisibilityBuilderoutput before changing draw order (Stage 4). - Dungeon PVS blowup (#95): watch the
update_countconvergence 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_outsidecellars 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; goldenCELLARRAYcandidate sets (cottage door, cellar exit, interior portal, building entry); PView convergence test (a cell receiving multiple clipped slices); doorwayOutsideViewnon-empty + sealed-cellarOutsideView-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 buildgreen; 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.