# Retail AC: Cell Transitions, Underground/Dungeons, and Seamless Indoor/Outdoor Rendering ## Independent Research Study — claude-sonnet-4-6 — 2026-06-02 > **Scope:** This is an independent study of the retail AC client (Sept 2013 EoR build, Sept PDB) > as primary oracle, cross-checked against ACE and the acdream codebase. Every non-trivial > claim is cited with `function @ 0xADDR (pseudo_c:LINE)` or `repo/file:LINE`. Inferences are > flagged explicitly. --- ## A. Cell Membership & Transitions (Physics) ### A1. How retail stores and updates `curr_cell` **The authoritative field is `SPHEREPATH::curr_cell` (acclient.h:32641).** `SPHEREPATH` (acclient.h:32625–32671) contains two parallel position/cell pairs: | Field | Purpose | |-------|---------| | `curr_cell` / `curr_pos` | The **accepted** position after the last completed move | | `check_cell` / `check_pos` | The **candidate** position being tested in the current sweep step | | `begin_cell` / `begin_pos` | The position at the **start** of this full transition | `SPHEREPATH::curr_cell` is **not** a cache of some ID-to-pointer lookup — it is the live pointer to the `CObjCell` the physics sphere currently inhabits. The `CPhysicsObj` also maintains `this->cell` (set by `enter_cell`/`leave_cell`), which mirrors `curr_cell` after `SetPositionInternal` completes the write-back. **Where `curr_cell` is updated during a transition:** 1. **`CTransition::validate_transition` @ `0x0050aa70` (pseudo_c:272547)** — the single canonical place where a step is *accepted*. ``` // On OK path (move accepted, check_pos != curr_pos): this->sphere_path.curr_pos = check_pos // line 272609-272611 this->sphere_path.curr_cell = check_cell // line 272612 // then resets check_pos/check_cell to the new curr: this->sphere_path.check_pos = curr_pos // line 272615-272616 this->sphere_path.check_cell = curr_cell // line 272617 // cell_array_valid cleared so next build_cell_array refreshes this->sphere_path.cell_array_valid = 0 // line 272618 // On non-OK path (collision, slide): // check_pos is reset BACK to curr_pos (line 272593) // curr_cell is NOT changed — sphere stays in curr_cell ``` **Key invariant**: `curr_cell` only advances when `validate_transition` accepts the move (returns `OK_TS` with `check_pos != curr_pos`). A bounce/slide resets `check_pos` to `curr_pos` without touching `curr_cell`. 2. **`CTransition::check_other_cells` @ `0x0050ae50` (pseudo_c:272717)** — updates `sphere_path.check_cell` after `find_cell_list` picks a containing cell: ``` CObjCell::find_cell_list(&cell_array, &var_4c, &sphere_path); // line 272725 // var_4c = the containing cell picked by point_in_cell scan sphere_path.check_cell = var_4c; // line 272761 if (var_4c != 0): adjust_check_pos(var_4c->id) // line 272765 ``` This is what _replaces_ `check_cell` with the correct new cell across a portal boundary during a sweep step. 3. **`CPhysicsObj::SetPositionInternal` @ `0x00515330` (pseudo_c:283399)** — the write-back from transition result to `CPhysicsObj::cell`: ``` CObjCell* curr_cell = arg2->sphere_path.curr_cell; // line 283403 if (curr_cell == 0): prepare_to_leave_visibility(); store_position(); else: if (this->cell == curr_cell): // same cell — just update the cell id on position/parts this->m_position.objcell_id = curr_pos.objcell_id; else: change_cell(this, curr_cell); // line 283456 ``` `CPhysicsObj::cell` is **only updated here**, from `sphere_path.curr_cell` delivered by the completed transition. There is NO intermediate re-derive from world position. 4. **`CPhysicsObj::change_cell` @ `0x00513390` (pseudo_c:281192)** — the actual leave/enter: ``` if (this->cell != 0): leave_cell(this, 1); if (arg2 != 0): enter_cell(this, arg2); else: this->cell = nullptr; ``` `enter_cell` @ `0x00510ed0` (pseudo_c:278928) calls `CObjCell::add_object(arg2, this)` to register the physics object in the new cell's object list. **Summary chain:** ``` transitional_insert (sweep loop) → insert_into_cell (per-cell BSP test) → check_other_cells (find_cell_list → pick containing cell → update check_cell) → validate_transition (accept: curr_cell = check_cell; reject: reset check to curr) → [loop until done] → SetPositionInternal (write curr_cell → change_cell → CPhysicsObj::cell) ``` ### A2. `find_cell_list`: candidate building and containing-cell selection **`CObjCell::find_cell_list` @ `0x0052b4e0` (pseudo_c:308742)** — this is the workhorse. Signature (from the 6-argument overload): ```cpp void CObjCell::find_cell_list( Position const* pos, uint32_t numSphere, CSphere const* spheres, CELLARRAY* cellArray, CObjCell** containingCell, // OUT: the single containing cell SPHEREPATH* path) ``` Step-by-step: 1. **Seed the starting cell** (lines 308751–308769): ``` if pos.objcell_id >= 0x100: visible = CEnvCell::GetVisible(id) // indoor else: visible = CLandCell::GetVisible(id) // outdoor if id >= 0x100: path->hits_interior_cell = 1 CELLARRAY::add_cell(cellArray, id, visible) else: CLandCell::add_all_outside_cells(pos, numSphere, spheres, cellArray) ``` 2. **Expand via `find_transit_cells`** (lines 308771–308786): Each cell already in the array is asked to add any neighboring cells that the sphere overlaps through portals: ``` for each cell in cellArray: cell->vtable->find_transit_cells(pos, numSphere, spheres, cellArray, path) ``` For `CEnvCell::find_transit_cells` @ `0x0052c820` (pseudo_c:309968): for each portal, checks if any sphere overlaps the portal plane (with radius epsilon). If so, either adds `portal.other_cell` (if resolved) or adds the portal's landcell side. For `CLandCell::find_transit_cells` @ `0x00533800` (pseudo_c:317603): calls `add_all_outside_cells` then `CSortCell::find_transit_cells`. 3. **Pick the containing cell** (`*containingCell`, lines 308788–308827): ``` *containingCell = nullptr for each cell in cellArray: blockOffset = LandDefs::get_block_offset(pos.objcell_id, cell.id) localPoint = sphere[0].center - blockOffset if cell->vtable->point_in_cell(localPoint): *containingCell = cell if cell.id >= 0x100: path->hits_interior_cell = 1 break // interior cell wins immediately, no further scan ``` **Interior cell wins over landcell** — once a `CEnvCell` is found to contain the point, the loop breaks. Outdoor landcells are not preferred over interior cells. 4. **`do_not_load_cells` prune** (lines 308829–308867): ``` if cellArray.do_not_load_cells AND pos.objcell_id >= 0x100: remove any cell from cellArray whose id is not: - the id of the 'visible' (GetVisible) cell, or - one of visible's stab_list members (its PVS set) ``` This prune runs **after** the containing-cell scan. It removes cells that are reachable via portal overlaps but not actually visible from the current interior starting cell. It only fires when `do_not_load_cells = 1` AND the position is already indoors (id >= 0x100). The prune uses `visible`'s `stab_list` array — the precomputed PVS of cells visible from that EnvCell — as the whitelist. **What stability the prune buys:** When the sphere straddles a portal into a neighboring `CEnvCell`, the transition normally adds that neighbor to the `cellArray` and may pick it as containing cell. With `do_not_load_cells`, neighbors NOT in the current cell's PVS are stripped — the sphere can only "move into" a cell that is visible from where it currently is. This prevents teleporting through walls into cells whose portals don't connect to the current room. **When `do_not_load_cells` is set:** - `CPhysicsObj::SetPositionInternal` @ `0x00515bd0` (pseudo_c:283930) sets it: `cell_array.do_not_load_cells = 1` (line 283930) before calling `find_cell_list`. This is the placement/teleport path, not the per-frame movement path. - Also set at `0x00519895` (pseudo_c:287856) in the detection manager. - The normal movement transition (`find_transitional_position`) path does NOT set `do_not_load_cells` — the sweep is allowed to discover any adjacent cell. **CELLARRAY struct (acclient.h:31574–31580):** ```cpp struct CELLARRAY { int added_outside; int do_not_load_cells; uint num_cells; DArray cells; // each entry: {uint cell_id; CObjCell* cell;} }; ``` ### A3. How retail avoids cell flicker at boundaries Retail's anti-flicker guarantee is **directional** — `curr_cell` only advances when a step is **accepted** by `validate_transition`. A blocked step resets `check_pos`/`check_cell` back to `curr_pos`/`curr_cell` (pseudo_c:272593). The player standing still fires collision tests that may push-back but never accept, so `curr_cell` never changes. The key mechanisms: 1. **Sweep-path tracking, not static lookup.** The sweep starts at `curr_cell` and advances `check_cell` through portals only when the sphere physically crosses through a portal (detected by `find_transit_cells`). A position jitter of ±8cm across a door frame does NOT cause `curr_cell` to flip — it would only flip if `validate_transition` accepted a step that moved `check_pos` past the portal. 2. **`point_in_cell` semantics.** The containing-cell selection in `find_cell_list` uses `CObjCell::point_in_cell` (vtable+0x84) which for `CEnvCell::point_in_cell` @ `0x0052c300` (pseudo_c:309677) does a BSP containment test: ``` eax_1 = this->vtable->point_in_cell(arg2) // delegates to CCellStruct::point_in_cell ``` `CCellStruct::point_in_cell` @ `0x005338f0` (pseudo_c:317657) tests the BSP. The BSP boundary is not a simple plane — it is the full convex cell volume defined by the portal geometry. So "across the threshold" in world space may still be "inside the cell" in BSP space for some margin. 3. **Interior cell wins the point_in_cell scan.** In the containing-cell scan (pseudo_c:308814-308820), once an interior cell (`id >= 0x100`) passes `point_in_cell`, the loop breaks immediately. This ensures that even if the outdoor landcell ALSO passes `point_in_cell` (because the player is standing ON a building's footprint), the indoor cell takes priority. 4. **`check_cell` is set from the collision result, not re-derived from world position.** After `check_other_cells` updates `check_cell` to the `find_cell_list` result, the next iteration of the sweep uses THAT `check_cell` as the source for `insert_into_cell`. There is no re-derive from the player's world XYZ. 5. **Standing still:** When `targetPos == currentPos`, the transition has zero steps. `find_transitional_position` returns immediately (ACE Transition.cs:528–529). `curr_cell` is never touched. _(Inference: based on the ACE port — retail likely has the same short-circuit.)_ **acdream's divergence:** `PhysicsEngine.cs:909/928` calls `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` which re-derives the cell from the final world XYZ via `CellTransit.FindCellList`. This is a static BFS from position. If the BSP push-back moves the sphere center 8cm outside the indoor cell's BSP volume, `FindCellList` can return the outdoor landcell — which is exactly the ping-pong symptom. Retail never does this; it reads `sphere_path.curr_cell` directly. ### A4. Indoor→outdoor and outdoor→indoor transitions; CCellPortal vs CBldPortal **CCellPortal (acclient.h:32300)** — connects two `CEnvCell`s within the same building or dungeon: ```cpp struct CCellPortal { uint other_cell_id; // id of the neighbor EnvCell CEnvCell* other_cell_ptr; // live pointer (null when not loaded) CPolygon* portal; // the polygon defining the opening int portal_side; // which side of the polygon is "inside" this cell int other_portal_id; // index of corresponding portal in other_cell int exact_match; // match only when crossing from exact side }; ``` **CBldPortal (acclient.h:32094)** — connects a building's `CBuildingObj` to the outdoor landscape or to an `EnvCell` within the building: ```cpp struct CBldPortal { int portal_side; // 0 or 1 uint other_cell_id; // 0 for outdoor exits int other_portal_id; // -1 for outdoor exits int exact_match; uint num_stabs; uint* stab_list; // PVS of interior cells visible through this portal float sidedness; }; ``` **Outdoor → indoor entry:** `CEnvCell::check_building_transit` @ `0x0052c5d0` (pseudo_c:309827) is called from `find_transit_cells`. For each portal in the building (CBldPortal), it tests whether any physics sphere overlaps the cell BSP (`CCellStruct::sphere_intersects_cell`). If so, it calls `CELLARRAY::add_cell(arg5, this->m_DID.id, this)` — adding the interior EnvCell to the collision candidate array. After the next `find_cell_list` run, `point_in_cell` on the indoor cell will win (interior > outdoor), and `check_cell` becomes the indoor cell. On the next `validate_transition` accept, `curr_cell` advances to the indoor cell. **Indoor → outdoor exit:** `CEnvCell::find_transit_cells` @ `0x0052c820` (pseudo_c:309968): when a portal's `other_cell_id == 0xFFFFFFFF` (exit portal — sentinel value), the code tests whether any sphere plane is within epsilon of the exit portal plane (pseudo_c:309983–310030). If so, it calls `CLandCell::add_all_outside_cells` (line 310120) — adding the surrounding landcells. The point_in_cell scan then picks the outdoor landcell. **Between interior cells:** Handled purely via `CCellPortal` in `find_transit_cells`. If the sphere overlaps the `CCellPortal.portal` polygon (plane-distance test with radius epsilon), `other_cell_ptr` (if non-null) is added to the array. ### A5. The two arrays: `CELLARRAY` for collision vs `curr_cell` for membership These are NOT the same. `CTransition` owns both: ```cpp // CTransition (acclient.h:52332–52335): SPHEREPATH sphere_path; // contains curr_cell, check_cell CELLARRAY cell_array; // the candidate array for collision testing ``` **`cell_array`** is rebuilt each step by `build_cell_array` → `find_cell_list`. It contains ALL cells whose BSPs the sphere might overlap (typically 1–4 cells at a boundary). Every cell in the array gets an `insert_into_cell` collision test. **`curr_cell`** in `sphere_path` is the single cell that CONTAINS the sphere center — the membership answer. It's updated only via `validate_transition`'s accept path. The relationship: each step's `find_cell_list` scan computes both (1) the candidate array for collision and (2) the containing cell (`*containingCell` arg). They share the same `find_cell_list` call but serve different purposes. --- ## B. Underground / Dungeons ### B6. Dungeon representation — EnvCell graph vs surface buildings **Surface buildings** (cottages, inns): - Built on top of a landblock with terrain. - `CLandBlockInfo.buildings` array references `CBuildingObj` instances. - Each building's `CBuildingObj` has `portals` (array of `CBldPortal*`) connecting it to the indoor `CEnvCell` graph and to the outdoor landscape. - The `CLandCell` for that terrain square is always present; the `CEnvCell` cells of the building sit "on top" of it spatially but are physically separate cells. - `CEnvCell::seen_outside` (acclient.h:30929, type `int`) is **non-zero** for cells that have at least one exit portal reaching the landscape (inn doorway cells, cellar stairs top-cell, etc.). **Dungeons:** - Represented as a pure `CEnvCell` graph, loaded from the DAT as the `CLandBlockInfo` for the dungeon block. There is no terrain `CLandCell` in the dungeon landblock. - All `CEnvCell`s in a dungeon have `seen_outside = 0` (pseudo_c:311370 shows it's zeroed on init; the unpack path at pseudo_c:311044 / 311057 can set it from DAT data, but dungeon cells universally have no outdoor reachability). - The dungeon is entered via a portal link from a surface landblock `CEnvCell` (the dungeon entrance cell) to the dungeon's first cell, mediated by the same `find_transit_cells` mechanism. **At runtime** there is no explicit "I am in a dungeon" boolean. The engine tests `curr_cell->seen_outside` to decide whether terrain/sky applies. ### B7. Player movement through a dungeon Cell tracking in a dungeon is identical to inside a surface building — the same `CEnvCell` sweep via `CCellPortal`. The dungeon cell graph is self-contained. The absence of `CLandCell` means `find_cell_list` never adds outdoor cells: - `CObjCell::find_cell_list` seeds from `CEnvCell::GetVisible(id)` (id >= 0x100 branch), adds only the starting EnvCell. - `CEnvCell::find_transit_cells` tests each portal. Exit portals with `other_cell_id == 0xFFFFFFFF` exist in surface buildings to reach the landscape; dungeon cells don't have such portals — their portals all connect to other dungeon `CEnvCell`s. Streaming/loading: `CEnvCell::grab_visible_cells` @ `0x0052e220` (pseudo_c:311880): ``` add_visible_cell(this->id) for each stab in stab_list: add_visible_cell(stab) // the PVS if seen_outside != 0: LScape::grab_visible_cells() // only for outdoor-reachable cells ``` Dungeon cells (seen_outside==0) never trigger `LScape::grab_visible_cells`. The landscape is completely excluded from their rendering context. ### B8. The "underground" flag **There is no explicit `is_dungeon` or `is_underground` boolean** on Position, landblock, or cell. The engine uses `CObjCell::seen_outside` (acclient.h:30929) as the semantic gate: - Non-zero: this cell can see the outside world (sky, terrain visible through portals/exits) - Zero: fully enclosed (pure dungeon cell, or interior cell with no exterior windows) The decision tree (from `SmartBox::RenderNormalMode` @ `0x00453aa0`, pseudo_c:92635): ``` edi_2 = (viewer_cell == null) ? 1 : 0 // no viewer cell = outdoor default if edi_2 == 0: ebx_1 = viewer_cell->seen_outside != 0 // 1=can see outside, 0=sealed if edi_2 == 0: if ebx_1: LScape::update_viewpoint + DrawInside(viewer_cell) // indoor+terrain else: DrawInside(viewer_cell) only (no terrain) else: LScape::update_viewpoint + LScape::draw // pure outdoor ``` This is the master terrain/sky gate. The `seen_outside` field on `viewer_cell` (the render-side cell for the viewer position) determines whether terrain renders. `CellManager::ChangePosition` @ `0x004559b0` (pseudo_c:94601) also reads `seen_outside` on the new `curr_cell` to decide whether to `LScape::release_all` vs `grab_visible_cells` (pseudo_c:94649–94661). For a cell with `seen_outside != 0`, it calls `LScape::update_loadpoint` to keep terrain around the outdoor cell ID loaded. --- ## C. Rendering Inside and Outside ### C9. The PView visibility traversal — one pass, one BFS Retail's render visibility is built by **`PView::ConstructView`** @ `0x005a57b0` (pseudo_c:433750). This is a **breadth-first portal traversal**, not a recursive frustum split. The same PView instance is reused for all cells seen from the current `viewer_cell`. ``` PView::ConstructView(this, rootCell, incomingPortalIdx): reset outside_view, master_timestamp, cell_todo_num, cell_draw_num InitCell(this, rootCell, 0xFFFF) // set up per-portal visibility masks InsCellTodoList(this, rootCell, 0f) // push root into the work queue while cell_todo_num > 0: cell = pop from cell_todo_list add cell to cell_draw_list cell.portal_view[last].cell_view_done = 1 if ClipPortals(this, cell, 0): // compute per-portal clip regions AddViewToPortals(this, cell) // propagate visibility through each portal ``` **`InitCell`** @ `0x005a4b70` (pseudo_c:432896) initializes the per-portal visibility state for the root cell: it checks each portal's plane against the current viewer position (`Render::FrameCurrent`) to determine which portals face the viewer. Portals that face away are marked as non-visible. The result is stored in `portal_view_type` structs on the `CEnvCell`. **`ClipPortals`** clips each visible portal to the accumulated clip region (the frustum intersection of all portals traversed so far). If a portal's clip region is non-empty, `AddViewToPortals` is called, which calls `ConstructView` **recursively** for the neighboring cell through that portal (pseudo_c:433879): ``` if arg5 != 1: PView::ConstructView(this, eax_4, arg2->other_portal_id) ``` The result is `cell_draw_list` — an ordered list of `CEnvCell*` to draw, built in visibility order from the viewer. **Output:** `PView::cell_draw_list` (acclient.h:45939) — a `DArray`, the ordered draw list. `PView::outside_view` accumulates outdoor portal entries for landscape/sky draws. ### C10. Outside seen through a doorway — exit portals and `seen_outside` When the traversal reaches a cell with an **exit portal** (CBldPortal with `other_cell_id == 0`), `PView::ConstructView(this, bldPortal, polygon, ...)` is called (the `CBldPortal` overload at `0x005a59a0`, pseudo_c:433827): ``` PView::ConstructView(this, bldPortal, polygon, arg4, arg5): test viewer position against portal plane (sidedness check): if portal_side correct: GetClip(this, sidedness, polygon, &clip_view, ...) if clip_view non-empty: eax_4 = CEnvCell::GetVisible(bldPortal->other_cell_id) // indoor neighbor if arg5 != 2: D3DPolyRender::DrawPortalPolyInternal(polygon, ...) // draw the portal hole PView::ConstructView(this, eax_4, bldPortal->other_portal_id) // recurse indoor ``` For **outdoor exit** portals specifically, the exit goes to the `outdoor_pview` via `PView::DrawPortal` @ `0x005a5ab0` (pseudo_c:433895). The `outdoor_pview` is the landscape PView; `DrawPortal` calls `ConstructView(outdoor_pview, bldPortal, portal, ...)`. Inside `PView::DrawCells` @ `0x005a4840` (pseudo_c:432709), when `outside_view.view_count > 0`: ``` Render::useSunlightSet(1) Render::PortalList = this LScape::draw(this->lscape) // terrain + sky through this portal's clip region D3DPolyRender::FlushAlphaList(0f) // ... then draw the indoor env cells ``` The landscape draw uses `Render::PortalList` (set to `this` PView) to clip the terrain to the portal opening's region — only the terrain visible through that portal hole is drawn. This is how the outside world appears through a doorway without a blue hole. **No blue-hole guarantee:** The portal hole is always either (a) filled by the `D3DPolyRender::DrawPortalPolyInternal` call (which masks the stencil/z-buffer for the opening) or (b) reveals a valid outdoor PView result. The `DrawPortalPolyInternal` call draws the portal polygon as a "window" into the outdoor view. The outdoor view is computed by `outdoor_pview` using its `ConstructView` with the portal's clip shape as the accumulated frustum. ### C11. How retail seals interiors — ceiling caps, entity clip, portal masking Retail's interior seal comes from the PView system itself: 1. **Portal-hole masking:** Each portal polygon is drawn as a "window" into the neighboring view. The rendering device clips draws to the visible portal region. Cells not in `cell_draw_list` are never drawn. 2. **`cell_draw_list` is the only draw gate.** `PView::DrawCells` iterates only `cell_draw_list.data[0..cell_draw_num]`. An entity or particle in a cell not in `cell_draw_list` is not drawn. There is no separate frustum cull — portal visibility IS the culling. 3. **Ceiling/floor capping:** The `CCellStruct` BSP for each `CEnvCell` includes all surfaces (floor, ceiling, walls). When `DrawEnvCell` renders the cell geometry, all surfaces including the ceiling are drawn. The only surfaces NOT drawn are portals (they get `DrawPortalPolyInternal` treatment instead of normal polygon rendering) — pseudo_c:432785–432791: ``` if portals[j].other_cell_ptr == 0xffffffff: D3DPolyRender::DrawPortalPolyInternal(portal, 0) ``` So: ceiling is always part of the cell mesh and always drawn. There is no "open top" unless the cell geometry has an actual hole. 4. **Entity draw:** `DrawCells` also calls `DrawObjCellForDummies` (pseudo_c:432878) for each cell in `cell_draw_list`, which draws entities registered in that cell's `object_list`. Entities in non-visible cells are never drawn. ### C12. Terrain and sky: the `seen_outside` gate The draw path (from `SmartBox::RenderNormalMode` @ `0x00453aa0`, pseudo_c:92635): ``` if viewer_cell == null OR viewer_cell->seen_outside != 0: // Player is outdoor OR in a cell that can see outside if viewer_cell != null (indoor-reachable-outside case): LScape::update_viewpoint(viewer.objcell_id) Render::update_viewpoint(&viewer) RenderDevice::DrawInside(viewer_cell) // fires PView traversal + portal terrain draws else (viewer_cell != null AND seen_outside == 0): // Player in sealed cell (dungeon or sealed room) LScape::update_viewpoint(viewer.objcell_id) Render::update_viewpoint(&viewer) RenderDevice::DrawInside(viewer_cell) // PView traversal only, no landscape ``` Wait — actually reading more carefully (pseudo_c:92665–92684): ``` if edi_2 == 0 (viewer_cell != null): if ebx_1 (seen_outside): LScape::update_viewpoint + DrawInside else: DrawInside only (no landscape update) else (viewer_cell == null): LScape::update_viewpoint + LScape::draw // pure outdoor: direct landscape draw ``` Terrain through portals is NOT drawn via `LScape::draw` when indoor — it's drawn inside `PView::DrawCells` via `LScape::draw(this->lscape)` only when `outside_view.view_count > 0` (there are outdoor portal entries). This means: - Sealed dungeon cell (`seen_outside=0`, no exit portals): `PView::DrawCells` never sets `outside_view.view_count > 0`, so `LScape::draw` is never called. - Building cell with exit portals: exit portal traversal adds an entry to `outside_view`; `LScape::draw` fires inside `DrawCells`. **Sky** follows the same path: `LScape::draw` includes sky. When there are no outdoor portal views, sky is not drawn. ### C13. Render `viewer_cell` vs physics `curr_cell` — the same graph, different holders **Retail uses the SAME physical `CObjCell*` for both physics and render, but held by different owners.** Physics: `CPhysicsObj::cell` (the player's `CPhysicsObj`) = current cell pointer, updated by `change_cell` after `SetPositionInternal`. Render: `SmartBox::viewer_cell` (acclient.h:35194, type `CObjCell*`) = the render entry point for `DrawInside`. This is updated by `SmartBox::update_viewer` @ `0x00453ce0` (pseudo_c:92761). **`SmartBox::update_viewer`** (pseudo_c:92761–92892) is called every frame. It: 1. Reads `this->player->cell` (the physics cell). 2. If `player->cell == null`: calls `reenter_visibility`, sets `viewer_cell = null`. 3. Otherwise, runs a **camera position transition**: `CTransition::find_valid_position` (line 92868) sweeps the `viewer_sphere` from `player_pos` to `viewer_sought_position` (the 3rd-person camera target position). 4. **Sets `viewer_cell = transition.sphere_path.curr_cell`** (line 92871) — directly from the camera transition result, NOT from re-deriving by position. 5. If the camera transition fails: falls back to `CPhysicsObj::AdjustPosition` on the viewer sphere, setting `viewer_cell` from that result. 6. If both fail: `viewer_cell = nullptr` (outdoor). **Key insight:** `SmartBox::viewer_cell` is a **separate tracked pointer** for the camera/render, but it is resolved via its own CTransition sweep (the camera spring-arm sweep). It does NOT re-derive from the camera's world XYZ via `find_cell_list` directly — it uses the transition result's `curr_cell`. When the camera is fully outdoor (no indoor cell found), `viewer_cell` is null, and the outdoor path fires. **The two cell pointers:** - `CPhysicsObj::cell` — physics membership, updated per-frame by `SetPositionInternal` - `SmartBox::viewer_cell` — render root, updated per-frame by `update_viewer` via its own camera transition sweep They share the same `CObjCell` graph (the runtime-loaded cells) but are tracked independently. Critically, `viewer_cell` does NOT necessarily equal `player->cell` — in 3rd-person mode the camera can be in a different cell than the player body. The render traversal uses `viewer_cell` (not `player->cell`) as the PView root. This is the CAMERA's cell, not the physics body's cell. --- ## D. Synthesis and Recommended acdream Architecture ### D14. Retail-faithful target architecture Given what the decomp shows, the correct architecture is: **Physics side (cell membership):** - `sphere_path.curr_cell` tracks the player's cell THROUGH the sweep. - `curr_cell` advances only when `validate_transition` accepts a step. - Blocked/standing-still steps never change `curr_cell`. - After the sweep, `SetPositionInternal` writes `sphere_path.curr_cell` → `CPhysicsObj::cell`. - No static re-derive from world position after the sweep. **Camera/render side (viewer cell):** - `SmartBox::viewer_cell` is resolved via its OWN `CTransition::find_valid_position` sweep on the camera eye sphere each frame. - This camera sweep returns its own `sphere_path.curr_cell` which becomes `viewer_cell`. - `viewer_cell` is what the PView traversal roots from — not `player->cell`. - The PView traversal (ConstructView → BFS → cell_draw_list) computes the entire visible set from `viewer_cell` in ONE pass. - Terrain/sky/landscape draw is gated on `viewer_cell->seen_outside`. **The two subsystems share the same cell graph** but track their own positions independently. They can be in different cells (player body vs camera). ### D15. Should acdream port `validate_transition`'s `curr_cell` advance + drop static re-derive? **Yes, unambiguously.** The decomp is clear on all three questions: **Q: Should membership advance inside the sweep?** Yes. `validate_transition` @ `0x0050aa70` is the sole gate for advancing `curr_cell`. Every accepted step updates `curr_cell`; every bounce resets `check_pos` to `curr_pos` without touching `curr_cell`. The current `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` call in acdream's `ResolveWithTransition` (PhysicsEngine.cs:909/928) must be replaced by reading `sp.CurCell` (= `sphere_path.curr_cell`) directly, exactly as retail's `SetPositionInternal` does (pseudo_c:283403). **Q: Should the `do_not_load_cells` prune be added?** Yes, but carefully. In retail it is set for PLACEMENT / TELEPORT paths (not for normal movement). For the physics SWEEP (normal movement), `do_not_load_cells = 0`. For `SetPositionInternal` placement path, `do_not_load_cells = 1`. acdream's `CellTransit.FindCellList` currently has no such prune — this is a latent source of spurious cross-wall cell candidates during teleports, but does NOT cause the per-frame ping-pong (since it's only for teleports). Port it as a separate step, after fixing the sweep tracking. **Q: Should render obey the physics `curr_cell` or a separate camera cell?** Retail uses a **separate** camera cell (`SmartBox::viewer_cell`) computed by its own camera transition sweep — NOT `player->cell` directly. The current acdream U.4c fix (GameWindow.cs:7163) correctly uses the physics `CurrCell` as the PView root when the camera is also indoors, but this diverges from retail for 3rd-person where camera ≠ player body. The most retail-faithful fix is to run a camera cell transition sweep (the retail `SmartBox::update_viewer` path) and use THAT result as the PView root, falling back to the physics cell when the camera sweep fails. For the current flicker/flap bugs, the U.4c fix (root at player cell, not camera eye) is empirically correct because acdream's camera doesn't yet have full collision. The retail-faithful end state requires a camera cell transition sweep. **Q: Should the render obey a single portal-visibility traversal?** Yes. Retail's `PView::ConstructView` + `DrawCells` is a single BFS that produces the complete `cell_draw_list` and `outside_view` in one pass. There is no separate "inside pass" + "outside pass" split. The outdoor terrain draws INSIDE `DrawCells` when `outside_view.view_count > 0` (i.e., exit portals were traversed). acdream's two-pipe architecture (WorldBuilder `RenderInsideOut` stencil + outdoor terrain) is the source of the seam bugs and should be replaced with a faithful PView BFS. ### D16. Must-port functions, integration order, risks, conformance tests #### Must-port functions (with retail addresses) | Priority | Function | Address | Purpose | |----------|---------- |---------|---------| | **P1** | `CTransition::validate_transition` | `0x0050aa70` | Accept-or-reject gate: advances `curr_cell` on accept, resets on reject | | **P1** | `CObjCell::find_cell_list` | `0x0052b4e0` | Candidate cell array + containing-cell detection | | **P1** | `CPhysicsObj::SetPositionInternal` | `0x00515330` | Write-back: reads `sphere_path.curr_cell` → `change_cell` | | **P2** | `CTransition::check_other_cells` | `0x0050ae50` | Cross-cell collision + updates `check_cell` from `find_cell_list` | | **P2** | `CEnvCell::find_transit_cells` | `0x0052c820` | Expand cell array through portals (the EnvCell variant) | | **P2** | `CLandCell::find_transit_cells` | `0x00533800` | Expand cell array from outdoor cells | | **P2** | `CEnvCell::check_building_transit` | `0x0052c5d0` | Detect sphere entering building through CBldPortal | | **P3** | `PView::ConstructView` | `0x005a57b0` | Render BFS: builds cell_draw_list from viewer_cell | | **P3** | `PView::InitCell` | `0x005a4b70` | Initialize per-portal vis state for root cell | | **P3** | `PView::DrawCells` | `0x005a4840` | Execute the draw list: terrain if outside_view > 0 | | **P3** | `SmartBox::update_viewer` | `0x00453ce0` | Camera cell tracking via its own transition sweep | | **P4** | `CEnvCell::find_visible_child_cell` | `0x0052dc50` | Find which portal-neighbor contains a point (used by camera placement) | #### Integration order (recommended) **Step 1 — Physics cell tracking (P1, eliminates the flicker bug):** In `PhysicsEngine.ResolveWithTransition`, after `transition.FindTransitionalPosition()` returns, read `transition.SpherePath.CurCell` directly. This is retail's `sphere_path.curr_cell` after `validate_transition` has run. Do NOT call `ResolveCellId(sp.GlobalSphere[0].Origin, ...)`. Specifically, in PhysicsEngine.cs: - Line ~909: replace `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` with direct `sp.CurCell?.ID ?? sp.CheckCellId` - Same at line ~928 (the partial-move path) - Add logic analogous to `SetPositionInternal`: if `transitCell == null`, treat as "left visibility"; if `transitCell != null`, use it directly. This is the highest-priority change. It should IMMEDIATELY stop the ping-pong because `CurCell` only changes when `validate_transition` accepts a step. **Step 2 — `do_not_load_cells` prune for teleports (P1 followup):** For the `CheckBuildingTransit` and teleport placement paths, set `do_not_load_cells = 1` in the `CELLARRAY` before calling `find_cell_list`. The prune logic (remove cells not in `visible.stab_list`) prevents spurious cross-wall candidates during placements. **Step 3 — Camera cell tracking (P3, for render correctness):** Port `SmartBox::update_viewer`'s camera transition sweep. This is a separate `CTransition::find_valid_position` call on a small `viewer_sphere` from the player position to the camera-sought position. The result's `sphere_path.curr_cell` becomes the PView root (`viewer_cell`). acdream's `PhysicsCameraCollisionProbe` and `RetailChaseCamera` are the existing hooks for this. **Step 4 — PView BFS render traversal (P3, eliminates indoor seam bugs):** Replace the current WorldBuilder `RenderInsideOut` stencil + outdoor draw split with a faithful `PView::ConstructView` BFS producing a `cell_draw_list`. Gate terrain draw on `outside_view.view_count > 0`. Gate entity/particle draws on cell membership in `cell_draw_list`. #### Main risks **Risk 1 — The sweep's `CurCell` may be null in edge cases.** Retail's `SetPositionInternal` handles `curr_cell == null` as "leave visibility". acdream must add the same null-guard. If the transition has zero steps (no movement), `CurCell` should be pre-seeded from `PhysicsBody.CellId` at transition init (the `begin_cell`). Failure mode: null-ref crash on first frame of movement. **Risk 2 — `check_cell` vs `curr_cell` confusion in `check_other_cells`.** `check_other_cells` sets `check_cell` (NOT `curr_cell`) to the `find_cell_list` result (pseudo_c:272761). `curr_cell` only advances in `validate_transition`. If Step 1 reads from the wrong field, the flicker comes back in a different form. **Risk 3 — Cellar-ramp issue (#98) may be a separate bug.** The stale contact-plane hypothesis (CLAUDE.md) suggests the cellar-ascent bug is a separate issue (stale ramp contact plane causing spurious Z-drift, not a cell tracking bug). Step 1 does NOT fix that; it only eliminates the doorway ping-pong. Keeping issues separate is important. **Risk 4 — The `do_not_load_cells` prune and multi-cell BSP.** The prune uses the CURRENT visible cell's `stab_list`. If acdream hasn't loaded all stab-list cells, the prune may incorrectly remove valid neighbor cells. Implement the prune only when `stab_list` is guaranteed to be fully populated (after `grab_visible_cells`). **Risk 5 — WorldBuilder rendering infrastructure vs PView.** acdream's entire renderer is built on WB's model. Porting PView from scratch is a large change. The safer incremental path: keep WB infrastructure but replace the `camera_inside_building` two-pipe split with a single PView-BFS-driven `cell_draw_list` that controls which draw passes run. **Risk 6 — `CObjCell::seen_outside` field in acdream's data layer.** The `seen_outside` flag must be populated from the DAT (it is serialized in `CEnvCell::UnPack` at pseudo_c:311044–311057). Verify that acdream's `EnvCell` data class carries and surfaces this field. If not, dungeon vs indoor vs outdoor classification cannot be retail-faithful. #### Conformance tests 1. **Ping-pong test:** Place the physics sphere at the exact doorway boundary (8cm inside the indoor cell). Run 100 ticks of zero-movement resolve. Assert `CurrCell` does NOT change across ticks. (Currently fails with acdream's static re-derive.) 2. **Doorway crossing test:** Move sphere from outdoor cell through a door into the indoor cell. Assert `CurrCell` transitions exactly once — not on the first frame the sphere overlaps the door frame, but on the first frame `validate_transition` accepts a step placing the sphere center inside the indoor cell's BSP. 3. **Blocked-step stability:** Set up a sphere pressed against a wall (collision returns non-OK). Run 10 ticks. Assert `CurrCell` never changes. 4. **Dungeon no-terrain test:** Place player in a dungeon cell with `seen_outside = 0`. Assert that the render pass does NOT draw terrain (no `LScape::draw` call or equivalent). 5. **Exit-portal terrain test:** Place player in an indoor cell that has an exit portal (`seen_outside != 0`). Assert that terrain IS drawn, clipped to the portal opening. 6. **`do_not_load_cells` prune test:** Teleport a physics body to a position inside an EnvCell whose PVS does not include an adjacent cell. Assert the adjacent cell's ID does NOT appear in the collision `CELLARRAY`. 7. **Camera cell tracking:** Move the camera to a position inside a building (3rd-person mode). Assert `viewer_cell` equals a valid indoor `CEnvCell`. Move camera back to outdoor. Assert `viewer_cell` becomes null/landcell. --- ## Appendix: Key Function Summary Table | Function | Address | Key behavior | |----------|---------|-------------| | `CObjCell::find_cell_list` | `0x0052b4e0` | Build candidate array + pick containing cell via point_in_cell | | `CEnvCell::find_transit_cells` | `0x0052c820` | Expand array through CCellPortal (EnvCell-to-EnvCell) | | `CEnvCell::check_building_transit` | `0x0052c5d0` | Detect sphere entering building via CBldPortal sphere test | | `CTransition::check_other_cells` | `0x0050ae50` | Find containing cell after primary insert; update check_cell | | `CTransition::validate_transition` | `0x0050aa70` | Accept step: curr_cell=check_cell; reject: reset check_pos | | `CTransition::transitional_insert` | `0x0050b6f0` | Outer sweep loop driving insert_into_cell + check_other_cells | | `CPhysicsObj::SetPositionInternal` | `0x00515330` | Write-back: sphere_path.curr_cell → change_cell | | `CPhysicsObj::change_cell` | `0x00513390` | leave_cell(old) + enter_cell(new) | | `CPhysicsObj::enter_cell` | `0x00510ed0` | Register obj in new cell's object_list; update cell pointer | | `CPhysicsObj::leave_cell` | `0x00510f50` | Deregister obj from old cell's object_list | | `CellManager::ChangePosition` | `0x004559b0` | High-level: update curr_cell + trigger landscape grab/release | | `SmartBox::update_viewer` | `0x00453ce0` | Camera cell tracking via own CTransition sweep | | `SmartBox::RenderNormalMode` | `0x00453aa0` | Master render gate: DrawInside vs LScape::draw on seen_outside | | `PView::ConstructView` | `0x005a57b0` | BFS from viewer_cell: builds cell_draw_list + outside_view | | `PView::InitCell` | `0x005a4b70` | Init per-portal vis masks for root cell | | `PView::DrawCells` | `0x005a4840` | Execute draw list; terrain if outside_view.view_count > 0 | | `CEnvCell::GetVisible` | `0x0052dc10` | Lookup cell by id in visible_cell_table (loaded cells only) | | `CEnvCell::find_visible_child_cell` | `0x0052dc50` | Find portal-neighbor containing a given point | | `CEnvCell::grab_visible_cells` | `0x0052e220` | Load self + stab_list; conditionally grab landscape | | `CObjCell::GetVisible` | `0x0052ad40` | Dispatch to CEnvCell::GetVisible or CLandCell::GetVisible | | `CLandCell::GetVisible` | `0x00532db0` | Lookup landcell by id | | `CLandCell::add_all_outside_cells` | `0x00533630` | Add all surrounding landcells to CELLARRAY (for outdoor) | --- ## Cross-references - **ACE** `PhysicsObj.cs:1171–1211` confirms `SetPositionInternal` reads `SpherePath.CurCell` directly (not via position re-derive). ACE `ObjCell.cs:335–413` confirms the `find_cell_list` logic including `do_not_load_cells` (`LoadCells` flag in ACE, CellArray.cs:8). ACE `Transition.cs:984–1091` (`ValidateTransition`) matches the retail decomp's curr_cell / check_cell advance/reset pattern exactly. - **WorldBuilder** `PortalRenderManager.cs` uses a flat stencil approach (not PView BFS) — this is a confirmed divergence from retail's recursive portal clip system. - **acdream** `PhysicsEngine.cs:909/928`: the two `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` calls are the specific lines to replace with `sp.CurCell` reads. - **acdream** `GameWindow.cs:7163`: the W2 UCG fix (using physics `CurrCell` as PView root) is directionally correct and should be kept, but note it is the player-cell root, not the retail camera-cell root.