acdream/docs/research/2026-06-02-retail-cell-render-study-codex.md
Erik 840c1b6442 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>
2026-06-02 13:58:51 +02:00

37 KiB

Retail cell transitions, dungeons, and seamless inside/outside rendering

Report date: 2026-06-02
Author: Codex research pass

Executive conclusion

Retail AC does not treat cell membership as a fresh static classification every frame. It carries SPHEREPATH.curr_cell and SPHEREPATH.check_cell through the collision sweep, asks CObjCell::find_cell_list for collision candidates and a candidate containing cell, accepts that candidate only when the transition step validates, and finally commits CPhysicsObj::cell from sphere_path.curr_cell in CPhysicsObj::SetPositionInternal (CTransition::check_other_cells @ 0x0050AE50, pseudo_c:272717-272755; CTransition::validate_transition @ 0x0050AA70, pseudo_c:272547-272619; CPhysicsObj::SetPositionInternal(CTransition const*) @ 0x00515330, pseudo_c:283399-283462).

The current acdream physics path already ports much of the transition internals, but then re-runs ResolveCellId(...) after the sweep on both success and partial failure, discarding the swept cell ID (src/AcDream.Core/Physics/PhysicsEngine.cs:847, src/AcDream.Core/Physics/PhysicsEngine.cs:866). That is the retail mismatch most likely responsible for doorway and room boundary ping-pong.

Retail render likewise uses the shared cell graph, not an unrelated "camera cell" system. The render loader follows the player/current position through CellManager::ChangePosition, reads the current CObjCell, and uses seen_outside to decide whether landscape, sunlight, and outdoor ambient state remain live (CellManager::ChangePosition @ 0x004559B0, pseudo_c:94601-94682). The portal renderer builds one portal-clipped view traversal with PView::ConstructView, PView::InitCell, PView::AddViewToPortals, and PView::DrawCells; outside visible through a doorway is an outside_view in the same traversal, not a blue clear-color gap or separate post-process (PView::ConstructView(CEnvCell*, ushort) @ 0x005A57B0, pseudo_c:433750-433789; PView::DrawPortal @ 0x005A5AB0, pseudo_c:433895-433933; PView::DrawCells @ 0x005A4840, pseudo_c:432709-432820).

The recommended acdream target is therefore:

  1. Make transition sweep state the sole source of membership updates.
  2. Port the remaining CELLARRAY/do_not_load_cells behavior where acdream builds static/cross-cell lists.
  3. Stop using render-side AABB camera-cell reclassification as the authority.
  4. Replace the split indoor/outdoor render branch with a single PView-style portal traversal rooted in the authoritative current cell, carrying per-cell portal views plus one OutsideView.

Sources checked

Primary retail sources:

  • docs/research/named-retail/acclient_2013_pseudo_c.txt
  • docs/research/named-retail/acclient.h
  • docs/research/named-retail/symbols.json

Reference cross-checks:

  • ACE physics: references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs, EnvCell.cs, LandCell.cs, CellArray.cs, Transition.cs, PhysicsObj.cs
  • ACViewer physics/render/dat: references/ACViewer/ACViewer/Physics/..., references/ACViewer/ACViewer/Render/R_Landblock.cs, R_EnvCell.cs, references/ACViewer/ACE/Source/ACE.DatLoader/...
  • WorldBuilder render base: references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs, references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs, references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs, references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs
  • holtburger dat/world notes: references/holtburger/crates/holtburger-dat/src/file_type/env_cell.rs, references/holtburger/crates/holtburger-world/src/state/liveness.rs, references/holtburger/crates/holtburger-common/src/position.rs
  • AC2D and Chorizite were searched for this topic; they did not provide a deeper portal-render model than retail/ACE/ACViewer/WorldBuilder. Chorizite is protocol-focused, not dat/render-focused.

A. Cell membership and transitions

A1. Retail stores membership as object state plus transition state

Retail has two relevant layers of cell state:

  • CPhysicsObj::cell and CPhysicsObj::m_position.objcell_id, the committed object membership (CPhysicsObj::change_cell @ 0x00513390, pseudo_c:281192-281215).
  • SPHEREPATH.curr_cell/curr_pos and SPHEREPATH.check_cell/check_pos, the in-flight sweep state. The retail SPHEREPATH struct contains begin_cell, curr_cell, check_cell, hits_interior_cell, and cell_array_valid (acclient.h:32625-32666).

CPhysicsObj::change_cell leaves the old cell, enters the new one when non-null, or clears m_position.objcell_id, part-array cell ids, and this->cell when null (CPhysicsObj::change_cell @ 0x00513390, pseudo_c:281192-281215).

CPhysicsObj::SetPositionInternal(CTransition const*) reads transition.sphere_path.curr_cell, not a static cell lookup. If the object is already in that cell, it updates position and child/part cell ids. If the cell differs, it calls change_cell(curr_cell) (CPhysicsObj::SetPositionInternal(CTransition const*) @ 0x00515330, pseudo_c:283399-283462; ACE mirror references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171-1215).

A2. The per-step sweep chain

The retail transition sweep is a state machine over check_pos/check_cell and curr_pos/curr_cell:

check_collisions:
    curr_cell = sphere_path.curr_cell
    check_pos = curr_pos
    check_cell = curr_cell
    FindObjCollisions(...)

transitional_insert:
    if check_cell is null: fail
    insert object into check_cell
    check_other_cells(check_cell)

check_other_cells:
    find_cell_list(cell_array, &newCell, sphere_path)
    test collision in every candidate cell except the already-tested cell
    if no collision:
        check_cell = newCell
        adjust_check_pos(newCell.id)

validate_transition:
    if OK and check_pos moved:
        curr_pos = check_pos
        curr_cell = check_cell
        reset check_pos/check_cell from current
    else if blocked/slide but not invalid:
        restore check_pos/check_cell to curr_pos/curr_cell
        return OK

SetPositionInternal:
    commit object cell from sphere_path.curr_cell

This pseudocode is from the decomp and ACE cross-checks: CTransition::check_collisions seeds check state from current state (CTransition::check_collisions @ 0x0050AA00, pseudo_c:272530-272542); CTransition::transitional_insert requires sphere_path.check_cell, inserts into it, then calls check_other_cells (CTransition::transitional_insert @ 0x0050B6F0, pseudo_c:273137-273185); CTransition::check_other_cells calls CObjCell::find_cell_list, tests other cells, then retargets check_cell through the containing cell (CTransition::check_other_cells @ 0x0050AE50, pseudo_c:272717-272755; ACE mirror references/ACE/Source/ACE.Server/Physics/Transition.cs:150-185); validate_transition advances curr_cell = check_cell only on an accepted move and restores check_cell = curr_cell on collision/slide recovery (CTransition::validate_transition @ 0x0050AA70, pseudo_c:272547-272619; ACE mirror references/ACE/Source/ACE.Server/Physics/Transition.cs:984-1091).

acdream's TransitionTypes largely mirrors this internally: it sets sp.CurCellId = sp.CheckCellId on accepted movement and resets check state from current state on blocked/slide recovery (src/AcDream.Core/Physics/TransitionTypes.cs:3388-3425). It also retargets CheckCellId after a successful other-cell query (src/AcDream.Core/Physics/TransitionTypes.cs:2061-2075). The break is after the sweep: PhysicsEngine.ResolveWithTransition wraps the final result in another ResolveCellId(...) call (src/AcDream.Core/Physics/PhysicsEngine.cs:847, src/AcDream.Core/Physics/PhysicsEngine.cs:866).

A3. How find_cell_list builds the cell array

Retail CELLARRAY has added_outside, do_not_load_cells, num_cells, and an array of CELLINFO (acclient.h:31574-31580). CObjCell::find_cell_list(Position const*, ...) clears num_cells and added_outside, inspects the low word of Position.objcell_id, and dispatches to EnvCell or LandCell visible-cell lookup (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308742-308756; CObjCell::GetVisible @ 0x0052AD40, pseudo_c:308209-308220).

If the origin cell is an EnvCell, retail marks sphere_path.hits_interior_cell = 1 and adds that EnvCell to the cell array (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308764-308766). If the origin cell is a land cell, it calls CLandCell::add_all_outside_cells to add the outdoor cells overlapped by the sphere(s) (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308769; CLandCell::add_all_outside_cells @ 0x00533630, pseudo_c:317499-317599).

Then, for every candidate cell in the array, retail calls that cell's find_transit_cells virtual (vtable + 0x80) to add portal/outdoor/building neighbors (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308775-308785). For EnvCells, CEnvCell::find_transit_cells iterates portals: loaded neighbor cells are tested by transforming the sphere into neighbor space and calling CCellStruct::sphere_intersects_cell; unloaded neighbor ids can be added as null cells; outside exit portals cause CLandCell::add_all_outside_cells to add outdoor land cells (CEnvCell::find_transit_cells @ 0x0052C820, pseudo_c:309968-310122; ACE mirror references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:311-371). For land cells, CLandCell::find_transit_cells calls add_all_outside_cells and then the base sorted-cell transit logic (CLandCell::find_transit_cells @ 0x00533800, pseudo_c:317603-317608; ACE mirror references/ACE/Source/ACE.Server/Physics/Common/LandCell.cs:277-280).

If the caller asks for a single containing cell, retail iterates the resulting candidate array, transforms the first sphere center into each candidate's local frame, and calls point_in_cell (vtable + 0x84). The first containing candidate is returned; if it is an EnvCell, hits_interior_cell is set and the search stops (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308801-308819; ACE mirror references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:365-383).

A4. do_not_load_cells prune

Retail prunes the cell array when CELLARRAY.do_not_load_cells != 0 and the origin position is an EnvCell. In that case, find_cell_list keeps the current visible cell and cells present in the current visible cell's visible-list, and removes other candidates (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308829-308867).

This flag is real retail state, not ACE invention: the struct contains do_not_load_cells (acclient.h:31574-31580), CPhysicsObj::calc_cross_cells_static sets it before building a cross-cell list (CPhysicsObj::calc_cross_cells_static @ 0x00515160, pseudo_c:283275-283344), and another position-check path sets it when a flag bit is present before CheckPositionInternal (CPhysicsObj position-check path @ 0x00515BD0 area, pseudo_c:283930-283946). ACE names the same behavior LoadCells; SetStatic() sets LoadCells = false, and SetDynamic() sets it true (references/ACE/Source/ACE.Server/Physics/Common/CellArray.cs:17-29).

The stability benefit is bounded: this prune limits speculative unloaded/remote cells for static/cross-cell calculations. It is not the primary doorway anti-flicker mechanism. The primary mechanism is still the accepted sweep state: blocked/slide cases restore check_pos/check_cell to curr_pos/curr_cell, and only accepted movement advances curr_cell (CTransition::validate_transition @ 0x0050AA70, pseudo_c:272593-272619).

acdream currently has no equivalent do_not_load_cells prune in CellTransit.BuildCellSetAndPickContaining; the indoor branch BFSes the portal graph with a hard maxIterations = 16, adds outdoors on any visited exit portal, then point-tests candidates (src/AcDream.Core/Physics/CellTransit.cs:426-537). That may over-admit candidates compared with retail when the caller intends a static/no-load cell list.

A5. How retail avoids boundary flicker

Retail avoids doorway and room-boundary flicker by combining four behaviors:

  1. Candidate discovery is sphere/path aware, not just point-grid classification. find_cell_list uses sphere overlap via transit-cell queries before choosing a containing cell (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308775-308819).
  2. The containing cell is assigned to check_cell during check_other_cells, before validation (CTransition::check_other_cells @ 0x0050AE50, pseudo_c:272717-272755; ACE mirror references/ACE/Source/ACE.Server/Physics/Transition.cs:179-183).
  3. validate_transition commits check_cell to curr_cell only if the step is accepted and the position actually moved (CTransition::validate_transition @ 0x0050AA70, pseudo_c:272608-272619).
  4. If the step is blocked, sliding, or adjusted but not invalid, validate_transition restores check state from the current state and returns OK; standing still or being pushed back does not independently choose a new cell (CTransition::validate_transition @ 0x0050AA70, pseudo_c:272593-272595; ACE mirror references/ACE/Source/ACE.Server/Physics/Transition.cs:1014-1017).

Therefore the answer to "what prevents flicker?" is a combination, but the load-bearing retail invariant is: membership changes only as part of an accepted swept transition, then SetPositionInternal commits from sphere_path.curr_cell (CPhysicsObj::SetPositionInternal(CTransition const*) @ 0x00515330, pseudo_c:283399-283462).

acdream violates that invariant by performing a final static re-derive from sp.GlobalSphere[0].Origin after the transition (src/AcDream.Core/Physics/PhysicsEngine.cs:847, src/AcDream.Core/Physics/PhysicsEngine.cs:866). The comments inside ResolveCellId already document previous ping-pong behavior caused by point/sphere static checks at indoor boundaries (src/AcDream.Core/Physics/PhysicsEngine.cs:286-315).

A6. Indoor to outdoor, outdoor to indoor, and interior to interior

Interior-to-interior movement is handled by CCellPortal records on EnvCells. The retail CEnvCell struct has num_portals and CCellPortal* portals (acclient.h:32072-32090); CCellPortal stores other_cell_id, other_cell_ptr, portal, portal_side, other_portal_id, and exact_match (acclient.h:32300-32308). ACViewer's dat loader confirms CellPortal fields: flags, polygon id, other cell id, and other portal id (references/ACViewer/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs:6-21).

Indoor-to-outdoor movement uses EnvCell exit portals. ACE's readable port treats OtherCellId == ushort.MaxValue as outside; when the sphere crosses or is near the exit portal plane, it sets checkOutside = true, and LandCell.add_all_outside_cells adds outdoor cells to the candidate array (references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:319-331, references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:369-370). Retail decomp shows the same shape in CEnvCell::find_transit_cells, including outside expansion through CLandCell::add_all_outside_cells (CEnvCell::find_transit_cells @ 0x0052C820, pseudo_c:309968-310122).

Outdoor-to-indoor movement uses building portals (CBldPortal) from landblock/building data. Retail CBldPortal stores other_cell_id, other_portal_id, num_stabs, and stab_list (acclient.h:32094-32103). ACViewer's dat loader confirms CBldPortal.OtherCellId, OtherPortalId, and StabList (references/ACViewer/ACE/Source/ACE.DatLoader/Entity/CBldPortal.cs:7-32). ACE's EnvCell.check_building_transit transforms the sphere into the target EnvCell and adds it when the EnvCell BSP says the sphere intersects (references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:128-144). acdream has an analogous CellTransit.CheckBuildingTransit (src/AcDream.Core/Physics/CellTransit.cs:299-341).

CCellPortal and CBldPortal therefore serve different roles. CCellPortal is an EnvCell-to-EnvCell or EnvCell-to-outside portal inside an EnvCell graph. CBldPortal is the building/landblock entry portal that connects an outdoor building instance to an interior EnvCell graph (acclient.h:32094-32103, acclient.h:32300-32308; WorldBuilder cross-check references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:45-135).

A7. Cell array vs membership

The collision cell array and current membership are related but not identical.

The cell array is a candidate set for collision and containing-cell choice. It can include current cell, adjacent EnvCells, exit outdoor land cells, building-interior cells, and possibly null/unloaded cell ids (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308742-308867; CEnvCell::find_transit_cells @ 0x0052C820, pseudo_c:309968-310122).

Membership is the single SPHEREPATH.curr_cell accepted by validate_transition and committed by SetPositionInternal (CTransition::validate_transition @ 0x0050AA70, pseudo_c:272608-272619; CPhysicsObj::SetPositionInternal(CTransition const*) @ 0x00515330, pseudo_c:283399-283462).

So find_cell_list supplies candidates and a proposed check_cell; validate_transition decides whether that proposal becomes curr_cell.

B. Underground and dungeons

B1. Runtime/dat representation

Dungeons and building interiors are both EnvCell graphs at runtime. ACViewer's dat loader describes EnvCells as cells whose low word starts at 0x0100, with surfaces, environment id, cell structure, position, cell portals, visible cells, static objects, and flags (references/ACViewer/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:7-63). Holtburger's dat parser reads the same shape: EnvCell has flags, surfaces, environment_id, cell_structure, position, portals, visible_cells, static_objects, and optional restriction object (references/holtburger/crates/holtburger-dat/src/file_type/env_cell.rs:8-84).

Building interiors differ from pure dungeons because the landblock still has outdoor terrain and building data. LandblockInfo records the number of EnvCells and building records (references/ACViewer/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs:7-58). WorldBuilder's PortalService starts from LandBlockInfo building portals and BFSes EnvCells to build portal groups (references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:45-135).

ACE identifies a pure dungeon landblock by all terrain heights being zero, at least one EnvCell, and no buildings (references/ACE/Source/ACE.Server/Entity/Landblock.cs:1266-1277). It also has HasDungeon for landblocks that contain EnvCells without necessarily being fully terrainless (references/ACE/Source/ACE.Server/Entity/Landblock.cs:1282-1302). ACViewer mirrors this distinction in rendering: it skips outdoor building/static/scenery generation for landblock.IsDungeon, but builds EnvCells for landblock.HasDungeon or when OutdoorEnvCells is enabled (references/ACViewer/ACViewer/Render/R_Landblock.cs:35-55, references/ACViewer/ACViewer/Render/R_Landblock.cs:83-103).

B2. Movement through dungeons

Movement through a dungeon uses the same EnvCell transition machinery as any interior. Current low word >= 0x0100 dispatches through EnvCell visible/transit logic, CEnvCell::find_transit_cells walks portals, and validate_transition commits accepted curr_cell (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308749-308769; CEnvCell::find_transit_cells @ 0x0052C820, pseudo_c:309968-310122; CTransition::validate_transition @ 0x0050AA70, pseudo_c:272608-272619).

Retail does not need a separate movement flag for "dungeon" in this path. The low-word cell range and EnvCell portal graph are sufficient for cell tracking. Holtburger's common position helper reflects the same client-side convention: low word >= 0x0100 means indoors, while outdoor cells are the 64 land cells (references/holtburger/crates/holtburger-common/src/position.rs:79-89, references/holtburger/crates/holtburger-common/src/position.rs:129-143).

B3. No explicit "underground" flag found

I did not find a retail Position or CObjCell field that directly means "underground". The explicit flag that matters for render/load policy is CObjCell.seen_outside (acclient.h:30915-30929). ACViewer/ACE names the EnvCell flag bit SeenOutside = 0x1 (references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:6-10), and the EnvCell dat loader exposes SeenOutside => Flags.HasFlag(EnvCellFlags.SeenOutside) (references/ACViewer/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:22-34).

Inference from sources: "underground" for render purposes is best modeled as current cell is an EnvCell with no outside visibility/reachability and, for a pure dungeon landblock, no meaningful outdoor terrain to draw. This inference is supported by retail CellManager::ChangePosition, which keeps or releases landscape based on seen_outside and current/outdoor state rather than an underground flag (CellManager::ChangePosition @ 0x004559B0, pseudo_c:94649-94682), and by ACE's IsDungeon heuristic (references/ACE/Source/ACE.Server/Entity/Landblock.cs:1266-1277).

C. Rendering inside and outside

C1. Retail render is a portal-view traversal

Retail PView stores an outside_view, an outdoor portal list, a cell draw list, a todo list, and an LScape* (acclient.h:45934-45944). CEnvCell stores per-cell portal-view arrays: num_view and DArray<portal_view_type*> portal_view (acclient.h:32072-32090). portal_view_type contains portal clip data, view_count, cell_view_done, view_timestamp, and update_count (acclient.h:32346-32355).

The render traversal starts with PView::ConstructView(CEnvCell*, ushort): it clears outside_view.view_count, increments master_timestamp, clears todo/draw lists, initializes the seed EnvCell, inserts it into the todo list, then repeatedly pops cells, adds them to the draw list, clips portals, and propagates views to neighbor portals (PView::ConstructView(CEnvCell*, ushort) @ 0x005A57B0, pseudo_c:433750-433789).

PView::InitCell initializes the current EnvCell's portal_view_type, decides which portals are visible for the current view slice, calculates portal clip metadata, calls Render::set_view, and sets update_count = view_count (PView::InitCell @ 0x005A4B70, pseudo_c:432896-433045). PView::AddToCell processes only new view slices from update_count to view_count (PView::AddToCell @ 0x005A4D90, pseudo_c:433050-433085). PView::AddViewToPortals initializes or appends to neighbor cells' portal views, queues neighbors, and calls SetOtherSeen for matching portal ids (PView::AddViewToPortals @ 0x005A52D0, pseudo_c:433446-433520).

This is important for issue #102: retail does not use a fixed small "max reprocess per cell" cap. It uses per-cell update_count watermarks and a todo list until the propagated view slices converge (PView::InitCell @ 0x005A4B70, pseudo_c:433043; PView::AddToCell @ 0x005A4D90, pseudo_c:433056-433080; PView::AddViewToPortals @ 0x005A52D0, pseudo_c:433494-433503). acdream currently has MaxReprocessPerCell = 4 in PortalVisibilityBuilder (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:41, src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:110).

C2. Outside through a doorway/window

Retail treats outside seen through an interior portal as part of the same PView. PView::DrawPortal selects a CBldPortal from outdoor_portal_list, adds the portal's stab views, calls PView::ConstructView(CBldPortal*, CPolygon*, ...), then draws cells if construction succeeds (PView::DrawPortal @ 0x005A5AB0, pseudo_c:433895-433933).

PView::ConstructView(CBldPortal*, CPolygon*, ...) classifies the viewer against the portal plane and portal side, clips through the portal polygon, gets the target EnvCell by other_cell_id, copies the view into that cell's portal view, optionally draws the portal polygon, and recursively constructs the EnvCell view (PView::ConstructView(CBldPortal*, CPolygon*, int, int) @ 0x005A59A0, pseudo_c:433827-433891).

PView::DrawCells checks outside_view.view_count; when nonzero, it enables sunlight, draws LScape, flushes alpha, then loops the cell draw list and draws EnvCell contents using the per-cell portal views (PView::DrawCells @ 0x005A4840, pseudo_c:432715-432807). This is the decomp evidence that a doorway to outside should render landscape/sky/rain/exterior content through the clipped outside view. It should not show clear color.

acdream has an OutsideView concept in PortalVisibilityBuilder, but it is currently owned by a separate render-side cell system and a split indoor branch (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:6-19, src/AcDream.App/Rendering/GameWindow.cs:11021-11235). The concept is directionally right; the authority and traversal convergence differ from retail.

C3. Interior sealing and clipping

Retail interior sealing is primarily geometry plus portal clipping:

  • EnvCells render their own surfaces, environment/cell structure, static objects, and lights (CEnvCell struct fields in acclient.h:32072-32090; ACViewer R_EnvCell draws EnvCell environment and static objects from EnvCell transforms/textures, references/ACViewer/ACViewer/Render/R_EnvCell.cs:24-82).
  • The visible cell list is portal-clipped by PView; each EnvCell draw uses its current portal views (PView::DrawCells @ 0x005A4840, pseudo_c:432737-432807).
  • CObjCell::find_visible_child_cell searches current EnvCell/portal-neighbor/visible cells by point_in_cell, so child/entity visibility is also cell-graph based rather than global AABB visibility (CEnvCell::find_visible_child_cell @ 0x0052DC50, pseudo_c:311397-311462).

Inference from sources: retail prevents wall/entity/particle bleed by drawing only the portal-visible EnvCells/entities under the current PView clips and by treating outside as a clipped outside_view. It is not relying on a broad "render all indoor then stencil terrain" split.

WorldBuilder's current/reference path is coarser. It builds building portal groups from LandBlockInfo and EnvCell BFS (references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:45-135), then VisibilityManager.RenderInsideOut marks all portals of current/frustum-visible building groups in stencil, punches depth, renders full EnvCells of current buildings, and renders terrain/scenery through the stencil (references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-154). That is useful as a Silk.NET implementation base, but it is not the retail PView algorithm.

C4. Terrain and sky draw policy

Retail exposes a simple outside test for the player position. SmartBox::is_player_outside reads the player object's m_position.objcell_id low word and returns true for outdoor cell ids, i.e. low word below the EnvCell range (SmartBox::is_player_outside @ 0x00451E80, pseudo_c:90996-91007). Position::get_outside_cell_id uses LandDefs::adjust_to_outside to derive the outdoor cell id from the position's origin and returns that adjusted outside id when applicable (Position::get_outside_cell_id @ 0x004527B0, pseudo_c:91552-91575).

CellManager::ChangePosition is the main render/load bridge. When moving to a new current cell, it prefetches cells, gets the CObjCell, and if the cell is outside or seen_outside or keep_lscape_loaded, it updates the landscape load point. If not, it releases all landscape. Later, if the current cell is outside or seen_outside, it copies sunlight and landscape ambient light; otherwise it sets a flat indoor ambient value (CellManager::ChangePosition @ 0x004559B0, pseudo_c:94601-94682).

So retail's terrain/sky policy is not "indoor means no outside ever." It is:

  • Outdoor current cell: landscape/sunlight/ambient are active.
  • EnvCell with seen_outside: landscape/sunlight/outdoor ambient remain active and can be drawn through portal views.
  • EnvCell without seen_outside: landscape is released and indoor ambient is used.

This maps to cottages/cellars/dungeons better than acdream's current split. acdream currently computes a separate render camera cell, gates a kill-switched indoor branch on ACDREAM_A8_INDOOR_BRANCH, renders sky when !cameraInsideCell || cameraInsideBuilding, and skips terrain when cameraInsideBuilding so RenderInsideOutAcdream can stencil-gate terrain (src/AcDream.App/Rendering/GameWindow.cs:7325-7356, src/AcDream.App/Rendering/GameWindow.cs:7539-7597, src/AcDream.App/Rendering/GameWindow.cs:7634-7645).

C5. Is render cell visibility the same graph as physics?

Verified: retail render and physics both operate on CObjCell/CEnvCell/CLandCell graph objects. Physics uses CObjCell::GetVisible, CEnvCell::find_transit_cells, CLandCell::add_all_outside_cells, and point_in_cell for transitions (CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308742-308867). Render uses CEnvCell portal arrays, CObjCell.seen_outside, CEnvCell::find_visible_child_cell, and PView over EnvCells (acclient.h:30915-30929, acclient.h:32072-32090, CEnvCell::find_visible_child_cell @ 0x0052DC50, pseudo_c:311397-311462; PView::ConstructView(CEnvCell*, ushort) @ 0x005A57B0, pseudo_c:433750-433789).

The render loader is explicitly fed by the player's/current position through CellManager::ChangePosition (CellManager::ChangePosition @ 0x004559B0, pseudo_c:94601-94682). That is the bridge acdream should mirror: render should be rooted in authoritative physics/current cell, even if camera collision/follow offset needs a child-cell lookup for view origin.

holtburger independently notes the same target for client-side visibility: its world-state liveness code currently uses a conservative approximation, with a TODO to replace it by detecting the player's EnvCell, reading SeenOutside, unioning current EnvCell visible cells, and merging outdoor visibility only for SeenOutside EnvCells (references/holtburger/crates/holtburger-world/src/state/liveness.rs:135-146).

D14. Retail-faithful target

Use one cell graph and one membership authority:

  • Core physics owns current membership: PhysicsBody.CellId/equivalent should be updated from transition SpherePath.CurCellId, not from a post-sweep ResolveCellId.
  • CellTransit remains the port of retail candidate discovery, but its output should be consumed as check_cell inside the transition, not as a standalone static classifier at the end of every tick.
  • Render receives the authoritative current cell id plus camera position. If the camera is offset from the player, render may call a retail-style visible child lookup (CEnvCell::find_visible_child_cell) within the same graph, but it should not run an independent AABB FindCameraCell as the source of truth.
  • Render builds one PView-style frame: per-cell portal views, a todo list, a draw list, and an OutsideView. It uses seen_outside to decide whether outside landscape/light is active.

This aligns with the roadmap's Phase U direction: replace the abandoned two-pipe indoor/outdoor split with a unified retail-faithful render pipeline.

D15. Specific decisions

Yes: membership should be advanced inside the transition sweep. acdream should remove the final static ResolveCellId calls from ResolveWithTransition and return sp.CurCellId/accepted transition state after FindTransitionalPosition and ValidateTransition, matching retail SetPositionInternal (CPhysicsObj::SetPositionInternal(CTransition const*) @ 0x00515330, pseudo_c:283399-283462; current mismatch src/AcDream.Core/Physics/PhysicsEngine.cs:847, src/AcDream.Core/Physics/PhysicsEngine.cs:866).

Yes: port the do_not_load_cells prune, but treat it as a secondary correctness/stability fix. Add explicit CellArray semantics to CellTransit or equivalent: AddedOutside, DoNotLoadCells, candidate ids and loaded cell pointers. Then apply the retail prune when building no-load/static lists from an EnvCell origin (CELLARRAY struct acclient.h:31574-31580; prune in CObjCell::find_cell_list @ 0x0052B4E0, pseudo_c:308829-308867; ACE SetStatic/SetDynamic references/ACE/Source/ACE.Server/Physics/Common/CellArray.cs:17-29).

Yes: render should obey physics current cell and a single portal traversal. acdream's current CellVisibility.FindCameraCell does AABB point checks with grace frames (src/AcDream.App/Rendering/CellVisibility.cs:301-380), and GameWindow gates a separate indoor branch with ACDREAM_A8_INDOOR_BRANCH (src/AcDream.App/Rendering/GameWindow.cs:7325-7356). Retail instead passes current position/cell into CellManager::ChangePosition, uses seen_outside, and traverses PView (CellManager::ChangePosition @ 0x004559B0, pseudo_c:94601-94682; PView::ConstructView(CEnvCell*, ushort) @ 0x005A57B0, pseudo_c:433750-433789).

D16. Must-port functions and integration order

  1. Physics result commit

    • Must-port/align: CTransition::validate_transition @ 0x0050AA70, CPhysicsObj::SetPositionInternal(CTransition const*) @ 0x00515330, CPhysicsObj::change_cell @ 0x00513390.
    • Work: return accepted sp.CurCellId/sp.CheckCellId according to retail state, remove post-sweep static ResolveCellId from success and partial-failure paths.
    • Tests: doorway stationary jitter test, room-to-room threshold test, cellar ramp top test, blocked-wall no-cell-change test. Assert cell id changes only after accepted movement.
  2. Cell array parity

    • Must-port/align: CObjCell::find_cell_list @ 0x0052B4E0, CEnvCell::find_transit_cells @ 0x0052C820, CLandCell::add_all_outside_cells @ 0x00533630, CELLARRAY::add_cell @ 0x006B4FF0, CELLARRAY::remove_cell @ 0x006B4E80.
    • Work: replace hard-cap BFS/static hash-set behavior with a closer CELLARRAY model; include loaded/null cell entries, added_outside, do_not_load_cells, and retail prune.
    • Tests: golden candidate arrays around a cottage doorway, cellar exit, interior room portal, and outdoor-to-building entry. Include no-load prune cases from EnvCell origin.
  3. Render cell root

    • Must-port/align: CellManager::ChangePosition @ 0x004559B0, SmartBox::is_player_outside @ 0x00451E80, Position::get_outside_cell_id @ 0x004527B0, CEnvCell::find_visible_child_cell @ 0x0052DC50.
    • Work: render frame root uses physics current cell id; seen_outside controls landscape/light availability. Camera child lookup should be graph/BSP based, not AABB-only.
    • Tests: inside EnvCell with seen_outside keeps outdoor light/landscape; sealed dungeon EnvCell suppresses landscape; camera near doorway does not strobe render branch.
  4. PView traversal

    • Must-port/align: PView::ConstructView(CEnvCell*, ushort) @ 0x005A57B0, PView::InitCell @ 0x005A4B70, PView::AddToCell @ 0x005A4D90, PView::AddViewToPortals @ 0x005A52D0, PView::DrawPortal @ 0x005A5AB0, PView::DrawCells @ 0x005A4840.
    • Work: replace MaxReprocessPerCell cap with portal_view_type.update_count-style propagation. Produce cell_draw_list and outside_view from one traversal. Draw landscape through outside_view, then EnvCells through per-cell views.
    • Tests: portal graph convergence test where a cell receives multiple clipped slices; doorway outside-view non-empty test; sealed cellar no-outside-view test; no clear-color pixel through door in visual QA.
  5. Render object/entity clipping

    • Must-port/align: CEnvCell::find_visible_child_cell @ 0x0052DC50 and PView draw-list semantics.
    • Work: cull or stencil entities/particles by visible cell/portal view, not just world frustum.
    • Tests: entity behind wall in adjacent EnvCell is not visible; entity visible through doorway remains visible; particles do not bleed through sealed walls.

Main risks

  • Removing post-sweep ResolveCellId will expose any missing CheckCellId updates inside acdream's transition port. Before deleting it outright, add diagnostics proving sp.CheckCellId/sp.CurCellId are updated by CheckOtherCells for the known doorway, room, and cellar cases (src/AcDream.Core/Physics/TransitionTypes.cs:2061-2075, src/AcDream.Core/Physics/TransitionTypes.cs:3388-3425).
  • acdream's CellTransit.FindTransitCellsSphere currently marks exitOutside = true for any exit portal encountered in BFS (src/AcDream.Core/Physics/CellTransit.cs:95-123). Retail/ACE gate outside expansion on portal-plane/sphere tests in the EnvCell transit path (CEnvCell::find_transit_cells @ 0x0052C820, pseudo_c:309968-310122; ACE references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:319-331). Revisit this before relying on the transition cell as final truth.
  • Render work can regress everything if it tries to preserve the old branch split. Implement the PView traversal as a frame product first and compare it against current PortalVisibilityBuilder outputs before changing draw order.
  • Dungeons need separate visual QA from cottages: pure dungeon landblocks should not draw outdoor terrain, while seen_outside building/cellar EnvCells may still need clipped outdoor draw (CellManager::ChangePosition @ 0x004559B0, pseudo_c:94649-94682; ACE dungeon heuristic references/ACE/Source/ACE.Server/Entity/Landblock.cs:1266-1277).

Final answer to the decision

The candidate fix direction is correct, with one clarification: do_not_load_cells is necessary for parity but not sufficient by itself. The load-bearing retail behavior is transition-owned membership. acdream should stop reclassifying the final static point after the sweep, commit the swept cell like SetPositionInternal, and make render consume that same current cell through a PView-style portal traversal with seen_outside/OutsideView.