# Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering **Study author:** Opus 4.8 (1M ctx), researcher "opus48-b" **Date:** 2026-06-02 **Primary oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named) **Cross-checks:** `references/ACE/Source/ACE.Server/Physics/*`, acdream current code, `acclient.h` verbatim structs. > **Citation convention.** `Class::method @ 0xADDR (pc:LINE)` cites the named pseudo-C at the given > address and line. `repo/path:LINE` cites a reference repo. **VERIFIED** = I read it in source. > **INFER** = a reasoned conclusion not directly stated. Where ACE and the decomp agree, I say so; > where I could not confirm something, I flag it. --- ## Executive summary (read this first) Retail tracks "the cell I'm in" as **one** value — `SPHEREPATH::curr_cell` — that is **carried through the collision sweep** and committed to `CPhysicsObj::cell` only when it actually changes. It is **never** re-derived from the final resting position. The single mechanism that makes cell membership stable at doorways is the trio **(a) accept-cell-on-successful-move** inside `validate_transition` (`curr_cell = check_cell` only when the move was OK and the position actually changed), **(b) the directional containing-cell picker** in `find_cell_list` (interior cells win, first interior hit breaks), and **(c) the `do_not_load_cells` prune** that removes any candidate cell that is neither the current cell nor in its visible/stab list. A blocked or standing-still step explicitly reverts to `curr_pos`/`curr_cell`, so it **cannot** flip the cell. Rendering is **the same cell graph**. The render's "camera cell" (`SmartBox::viewer_cell`) is produced by running a *second* transition (the camera spring-arm) and reading **its** `sphere_path.curr_cell` (`SmartBox::update_viewer @ 0x453CE0`, pc:92871). The visible set is built by **one** portal-visibility BFS (`PView::ConstructView`), and the outside is drawn seamlessly through a doorway because **exit portals** (those whose `other_cell_id == 0xFFFF`) contribute a clip region to an `outside_view` that triggers `LScape::draw` clipped to the doorway extent (`PView::ClipPortals @ 0x5A5520` pc:433662-433685; `PView::DrawCells @ 0x5A4840` pc:432715-432719). There is **no** separate "inside / outside" stencil pass and **no** independent render cell system in retail. acdream's flicker is caused by exactly the thing retail does not do: after running the (correct) swept transition, `PhysicsEngine.ResolveWithTransition` **throws away** the swept cell (`sp.CheckCellId`/ `sp.CurCellId`) and calls `ResolveCellId(sp.GlobalSphere[0].Origin, …)` to re-derive membership from the final static origin (`PhysicsEngine.cs:909` and `:928`). Because collision push-back jitters that origin ±~8 cm across a cell boundary, the re-derive oscillates. **The fix is small and low-risk: return the swept cell.** acdream already advances `sp.CurCellId = sp.CheckCellId` inside its `ValidateTransition` (`TransitionTypes.cs:3408`) — the machinery exists; only the consumer is wrong. The render-side fix is to root visibility at the physics cell (W2 already adds `ComputeVisibilityFromRoot`) and to draw the landscape clipped to exit-portal regions (retail `outside_view` + `LScape::draw`). --- # A. Cell membership & transitions (physics) ## A0. The data: how "the cell I'm in" is stored There are **three** distinct cell pointers, on two objects: **On `CPhysicsObj` (the committed, between-frames truth):** - `CPhysicsObj::cell` — the object's *committed* current cell. `acclient.h` (the object is large; `cell` is the resident cell pointer set by `enter_cell`/`leave_cell`). - `CPhysicsObj::m_position.objcell_id` — the committed cell **id** (low 16 bits = cell-within-landblock, high 16 = landblock). **On `SPHEREPATH` (the per-transition working state):** `SPHEREPATH` struct verbatim (`acclient.h:32625-32671`, VERIFIED): ```c struct SPHEREPATH { unsigned int num_sphere; CSphere *local_sphere; ... CSphere *global_sphere; ... AC1Legacy::Vector3 *global_curr_center; // current sphere center (advances per sub-step) ... CObjCell *begin_cell; Position *begin_pos; Position *end_pos; CObjCell *curr_cell; Position curr_pos; // the ACCEPTED cell + pos so far AC1Legacy::Vector3 global_offset; int step_up; ... int collide; CObjCell *check_cell; Position check_pos; // the CANDIDATE cell + pos being tested SPHEREPATH::InsertType insert_type; int step_down; ... CObjCell *backup_cell; Position backup_check_pos; int obstruction_ethereal; int hits_interior_cell; // set when the candidate set touches an EnvCell int bldg_check; ... int cell_array_valid; // is the cached CELLARRAY still good for this check_pos? ... }; ``` The mental model: **`curr_cell` is "where I have validly reached so far"; `check_cell` is "the cell of the position I'm trying next."** The transition advances `check_*`, tests it, and on success promotes it into `curr_*`. At the very end, the *committed* `CPhysicsObj::cell` is synced from `sphere_path.curr_cell`. `CELLARRAY` (the collision candidate set) is a fourth thing, separate from `curr_cell` — see A5. `CELLARRAY` verbatim (`acclient.h:31574-31580`, VERIFIED): ```c struct CELLARRAY { int added_outside; // guards add_all_outside_cells (add outdoors once per build) int do_not_load_cells; // the prune flag (see A2) unsigned int num_cells; DArray cells; // CELLINFO = { uint cell_id; CObjCell* cell; } (acclient.h:31925) }; ``` ## A1. The full update chain (per physics tick) I traced the chain end-to-end. **VERIFIED** at every step: ``` CPhysicsObj::UpdateObjectInternal (per-tick body, ~pc:283600+) └─ UpdatePositionInternal @ 0x512C30 (pc:280817) // compute desired Frame offset └─ eax_10 = CPhysicsObj::transition(this, m_position, dest, 0) @ 0x512DC0 (pc:280904) │ └─ CTransition::init_path(result, this->cell, begin, end) @ 0x509E60 (pc:271982) │ │ └─ SPHEREPATH::init_path @ 0x50CE20 (pc:274359): │ │ curr_cell = begin_cell = this->cell; curr_pos = begin_pos; // SEED │ └─ CTransition::find_valid_position @ 0x50C310 (pc:273890) │ └─ (TRANSITION_INSERT) CTransition::find_transitional_position @ 0x50BDF0 (pc:273613) │ └─ FOR each of var_48 sub-steps: │ check_pos += global_offset // advance candidate │ var_44 = validate_transition(transitional_insert(this,3), &redo) │ ▲ transitional_insert @ 0x50B6F0 (pc:273137) // the stepper │ ▲ validate_transition @ 0x50AA70 (pc:272547) // accept/advance └─ if (eax_10 != 0) CPhysicsObj::SetPositionInternal(this, eax_10) @ 0x515330 (pc:283696) └─ curr_cell = arg2->sphere_path.curr_cell; // READ the swept cell └─ if (this->cell != curr_cell) change_cell(this, curr_cell); // COMMIT only on change └─ set_frame(this, &arg2->sphere_path.curr_pos.frame); // commit position ``` The per-tick body at pc:283673 (VERIFIED): `class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0);` then pc:283696: `CPhysicsObj::SetPositionInternal(this, eax_10);`. **The cell that ends up on the object is read straight out of the transition's `sphere_path.curr_cell`. No static re-derive is performed anywhere in this chain.** ### A1.1 `transitional_insert` — the sub-step stepper `CTransition::transitional_insert @ 0x50B6F0` (pc:273137, VERIFIED). For up to `arg2` insertion attempts it: 1. `edi = insert_into_cell(this, sphere_path.check_cell, arg2)` (pc:273153) — collide the candidate sphere against `check_cell`'s BSP. 2. On `OK_TS`: `edi = check_other_cells(this, sphere_path.check_cell)` (pc:273161) — test every *other* cell the sphere overlaps (via `find_cell_list`) and **retarget `check_cell` to the containing cell**. 3. Switch on the state: `COLLIDED_TS` returns (blocked); `ADJUSTED_TS`/`SLID_TS` clear `neg_poly_hit` and continue; on `OK_TS` it handles step-down / edge-slide / slide-sphere. Key: `check_other_cells` is where, mid-sweep, the **candidate cell is reassigned** to the cell that actually contains the swept sphere center. So as the sphere crosses a portal during the sweep, `check_cell` follows it cell-by-cell. ### A1.2 `validate_transition` — accept the move and advance `curr_cell` `CTransition::validate_transition @ 0x50AA70` (pc:272547, VERIFIED). This is the linchpin. Structure: ```c result = arg2; // the TransitionState from transitional_insert if (result != OK_TS) { // ── blocked / slid / adjusted ── if (result in (OK_TS, SLID_TS]) { // collided/adjusted/slid ... restore last-known contact plane, kill velocity ... set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // REVERT (pc:272593) build_cell_array(this, nullptr); result = OK_TS; } } else { // ── OK ── if (check_pos.objcell_id == curr_pos.objcell_id // (same cell && && Frame::is_equal(check_pos.frame, curr_pos.frame)) // same frame) → no movement goto done; // accept as-is, do NOT advance // else: real movement → PROMOTE check → curr: label_50aba9: curr_pos.objcell_id = check_pos.objcell_id; // (pc:272610) curr_pos.frame = check_pos.frame; curr_cell = check_cell; // *** ADVANCE MEMBERSHIP *** (pc:272612) cache_global_curr_center(&sphere_path); // reset check_* = curr_* for next sub-step: check_pos.objcell_id = curr_pos.objcell_id; check_pos.frame = curr_pos.frame; check_cell = curr_cell; } ``` **The two guarantees that kill flicker live here, VERIFIED:** - **Blocked/slid path (pc:272593):** `set_check_pos(curr_pos, curr_cell)` — the candidate is thrown away and reset to the *current* (last-accepted) position/cell. A wall bump does **not** change `curr_cell`. - **OK-but-didn't-move path (pc:272600-272605):** if `check_pos == curr_pos` (same id and frame), `goto done` — **no promotion**. Standing still does **not** change `curr_cell`. - **OK-and-moved path (pc:272608-272619):** *only here* is `curr_cell = check_cell` executed. **ACE cross-check (agrees exactly):** `Transition.ValidateTransition` (ACE `Transition.cs:984`). On `transitionState != OK` and not `Invalid`, it calls `SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell)` (ACE `Transition.cs:1014`) — revert. On `OK`, `SetCurrentCheckPos()` (ACE `Transition.cs:1084-1091`) does `SpherePath.CurPos = CheckPos; SpherePath.CurCell = SpherePath.CheckCell;` — advance. The gate is `transitionState != OK || CheckPos.Equals(CurPos)` (ACE `Transition.cs:990`). Same logic, same membership advance. ### A1.3 `SetPositionInternal(CTransition)` — commit, only on change `CPhysicsObj::SetPositionInternal @ 0x515330` (pc:283399, VERIFIED): ```c curr_cell = arg2->sphere_path.curr_cell; // (pc:283403) if (curr_cell == 0) { ... GotoLostCell ... } // left the world else { if (this->cell == curr_cell) { // SAME cell → just refresh ids (pc:283414) this->m_position.objcell_id = sphere_path.curr_pos.objcell_id; ... SetCellID on parts/children ... } else CPhysicsObj::change_cell(this, curr_cell); // DIFFERENT cell → leave+enter (pc:283456) CPhysicsObj::set_frame(this, &sphere_path.curr_pos.frame); ... copy contact_plane, transient_state from transition ... } ``` **`change_cell` only fires when `this->cell != curr_cell`.** Since `curr_cell` came from `validate_transition` (stable across blocks/standing-still), the committed cell is stable too. `CPhysicsObj::change_cell @ 0x513390` (pc:281192, VERIFIED): `if (this->cell) leave_cell(this,1); if (arg2) enter_cell(this, arg2); else { m_position.objcell_id = 0; cell = null; }`. `leave_cell`/`enter_cell` manage the cell's `shadow_object_list`/`object_list` membership and part-array cell ids. ## A2. `find_cell_list` — building the candidate array & picking the containing cell `CObjCell::find_cell_list` has several overloads. The one used everywhere through the sweep is the 3-arg forwarder `find_cell_list(CELLARRAY*, CObjCell** out, SPHEREPATH*) @ 0x52B960` (pc:309085) which forwards to the master overload `find_cell_list(Position, num_sphere, CSphere, CELLARRAY, CObjCell** out, SPHEREPATH) @ 0x52B4E0` (pc:308742), passing `check_pos`, `num_sphere`, `global_sphere`. **Master overload, VERIFIED (pc:308742-308869). Annotated:** ```c edi = arg4; // CELLARRAY edi->num_cells = 0; edi->added_outside = 0; objcell_id = arg1->objcell_id; // the position's current cell id visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // indoor : CLandCell::GetVisible(objcell_id); // outdoor // (1) seed the array with the current cell (indoor) or the outdoor landcells: if (objcell_id >= 0x100) { // INDOOR if (arg6) arg6->hits_interior_cell = 1; CELLARRAY::add_cell(edi, objcell_id, visibleCell); } else // OUTDOOR CLandCell::add_all_outside_cells(arg1, num_sphere, sphere, edi); // (pc:308769) if (visibleCell != 0 && num_sphere != 0) { // (2) EXPAND: each cell contributes its transit neighbors (portals / building portals / outside): for (i in 0..num_cells) edi->cells[i].cell->vtable->find_transit_cells(arg1, num_sphere, sphere, edi, arg6); // +0x80 // (3) PICK the single containing cell into *arg5: if (arg5) { *arg5 = null; for (i in 0..num_cells) { cell = edi->cells[i].cell; blockOffset = LandDefs::get_block_offset(arg1->objcell_id, cell.id); localCenter = sphere.center - blockOffset; if (cell->vtable->point_in_cell(&localCenter)) { // +0x84 *arg5 = cell; if ((cell.id & 0xFFFF) >= 0x100) { // INTERIOR cell wins: if (arg6) arg6->hits_interior_cell = 1; break; // *** first interior hit wins *** } // outdoor hit: keep scanning (an interior cell may still contain the point) } } } // (4) PRUNE (do_not_load_cells), only when currently in an interior cell: if (edi->do_not_load_cells && (arg1->objcell_id & 0xFFFF) >= 0x100) { for (i in 0..num_cells) { cell_id = edi->cells[i].cell_id; if (cell_id == visibleCell->m_DID.id) continue; // keep the current cell found = false; for (stab in visibleCell->stab_list[0..num_stabs]) if (cell_id == stab) { found=true; break; } if (!found) CELLARRAY::remove_cell(edi, i); // drop "stranger" cells } } } ``` The `arg4 + 0x28` / `arg4 + 0xe0`/`+0xe4` field offsets in the raw decomp (pc:308839, 308846, 308851) resolve to `visibleCell->m_DID.id` and `visibleCell->num_stabs`/`visibleCell->stab_list` — confirmed by `CObjCell` layout (`acclient.h:30927-30928`: `unsigned int num_stabs; unsigned int *stab_list;`). **ACE cross-check (agrees exactly):** `ObjCell.find_cell_list` (ACE `ObjCell.cs:335-414`). - Picker breaks on first interior cell containing the point (ACE `ObjCell.cs:378-382`). - Prune: `if (!cellArray.LoadCells && (position.ObjCellID & 0xFFFF) >= 0x100)` removes any cell that is neither `visibleCell.ID` nor in `((EnvCell)visibleCell).VisibleCells` (ACE `ObjCell.cs:387-413`). (ACE inverts the name: `LoadCells == !do_not_load_cells`.) ### A2.1 What `do_not_load_cells` is, when it's set, what it buys **What it is:** a flag on the `CELLARRAY` that, when set, restricts the candidate cell set to *(the current cell)* ∪ *(its visible/stab list)*. The "stab list" of a `CEnvCell` is the set of cell ids the dat marks as visible/reachable from that cell (`CObjCell::stab_list`, also driving `find_visible_child_cell` and the render's `add_views`). Outdoor landcells are **never** in an interior cell's stab list, so the prune drops them. **When it's set:** `CPhysicsObj::SetPositionInternal(Position, SetPositionStruct, CTransition) @ 0x515BD0` (pc:283929-283930, VERIFIED): `if ((arg3->flags & 0x20) != 0) edi->cell_array.do_not_load_cells = 1;`. i.e., it's a per-call option keyed on `SetPositionStruct` flag `0x20`. This flag is set by callers that move the object **without** wanting new cells streamed in / without crossing out of the known cell set — most relevantly authoritative position teleports and constrained sets where the server already told us the cell. **INFER (medium confidence):** during ordinary frame movement (`CPhysicsObj::transition`) the flag is *not* set, so the prune does not run on every walk-tick; it's specifically a stability guard for set-position operations. The flicker-killing for ordinary walking comes from A1.2's accept-on-move + A2's directional picker, *not* from the prune. The prune's stability value is: when you ask "which cell is this position in?" during a constrained set, you never accidentally promote into an outdoor landcell or a far interior cell just because the foot sphere clipped its bounding volume. **INFER:** acdream's analogue would set this for server `UpdatePosition` and any "snap to known cell" path, not for free movement. (acdream currently has *no* `do_not_load_cells` — it instead bolts a `DoorwayHoldMargin` hysteresis onto the static re-derive; see D.) ## A3. Precisely how retail avoids cell flicker (the answer) It is a **combination**, with the dominant mechanism being **swept-path containment with accept-on-move**: 1. **Membership is carried, not re-derived.** `curr_cell` persists across ticks via `CPhysicsObj::cell` and is only ever changed inside `validate_transition` on a *successful, position-changing* sub-step (pc:272612). A tick that ends blocked or standing-still leaves `curr_cell` exactly where it was (pc:272593, pc:272600-272605). **This is the property acdream lacks** — acdream recomputes from the static origin every tick. 2. **The picker is directional/priority-ordered.** When the candidate set *is* rebuilt (mid-sweep via `check_other_cells`, or on a `do_not_load_cells` set), `find_cell_list` breaks on the **first interior cell** that contains the point (pc:308814-308819). Interior cells dominate outdoor cells. So at the threshold, as long as the foot sphere's center is inside the vestibule's `cell_bsp`, the vestibule wins even though the outdoor landcell also overlaps the sphere. 3. **`point_in_cell` is a precise BSP/leaf test, not a bounding-box test.** `CEnvCell::point_in_cell @ 0x52C300` (pc:309677, VERIFIED): transforms the global point into the cell's local frame (`Frame::globaltolocal`) then `CCellStruct::point_in_cell(structure, localPoint)` — a test against the cell's `cell_bsp` leaf volume (`CCellStruct.cell_bsp`, `acclient.h:32289`). `CLandCell::point_in_cell @ 0x52D40` (pc:316941) tests `find_terrain_poly` — the point is in the landcell iff a terrain triangle contains it. Because `point_in_cell` is exact, the "containing cell" is unambiguous for a given center. 4. **The `do_not_load_cells` prune** (A2.1) is the *additional* guard for set-position; it removes stranger cells from the candidate array so a constrained set cannot drift the cell. The flicker acdream sees (`0xA9B40170 ↔ 0xA9B40031` at a static position) is structurally impossible in retail: retail would have committed `curr_cell = 0xA9B40170` once (when the sweep that crossed the doorway succeeded), and every subsequent standing-still tick hits `validate_transition`'s "didn't move → don't promote" branch (pc:272600-272605), so the cell never re-evaluates against the jittered origin at all. ## A4. Transitions: indoor↔outdoor, interior↔interior; `CCellPortal` vs `CBldPortal` Two portal types, two directions: ### A4.1 `CCellPortal` (interior↔interior, and interior→exterior) `CCellPortal` verbatim (`acclient.h:32300-32308`, VERIFIED): ```c struct CCellPortal { unsigned int other_cell_id; // 0xFFFFFFFF (==0xFFFF low) → EXTERIOR portal (leads outside) CEnvCell *other_cell_ptr; // resolved neighbor (or null) CPolygon *portal; // the portal polygon (its plane = the doorway plane) int portal_side; // which half-space is "inside" int other_portal_id; int exact_match; }; ``` `CCellPortal::GetOtherCell @ 0x53BA30` (pc:324830, VERIFIED) = `CEnvCell::GetVisible(other_cell_id)`. **Interior→interior expansion** is in `CEnvCell::find_transit_cells @ 0x52C820` (pc:309968, VERIFIED): for each of the cell's `portals[]`: - `other = CCellPortal::GetOtherCell(portal)`. If non-null and the sphere intersects `other->structure` (`CCellStruct::sphere_intersects_cell != OUTSIDE`, pc:310052), `CELLARRAY::add_cell(other)` (pc:310054). - If `other == null` (an **exterior** portal, `other_cell_id == 0xFFFF`), it instead does a plane-distance test of the sphere against the portal poly; if the sphere is on/through the portal it sets a local flag `var_44` (pc:310099). After processing all portals, `if (var_44) CLandCell::add_all_outside_cells(...)` (pc:310119-310120) — **this is how the outdoor landcells enter the physics candidate set when the player is at/through an exit doorway**, so collision against outdoor terrain works at the threshold. **ACE cross-check:** `EnvCell.find_transit_cells` (ACE `EnvCell.cs:311-370`) — same: portal loop, sphere intersect test, and `LandCell.add_all_outside_cells` at the end (ACE `EnvCell.cs:370`). ### A4.2 `CBldPortal` (exterior→interior building entry) `CBldPortal` verbatim (`acclient.h:32094-32103`, VERIFIED): ```c struct CBldPortal { int portal_side; unsigned int other_cell_id; // the interior EnvCell this building portal leads into int other_portal_id; int exact_match; unsigned int num_stabs; unsigned int *stab_list; float sidedness; }; ``` When the player is in an **outdoor** landcell, the landcell's `CSortCell` may hold a `CBuildingObj`. `CLandCell::find_transit_cells @ 0x533800` (pc:317603, VERIFIED): `add_all_outside_cells(...)` then `CSortCell::find_transit_cells(...)` (pc:317607) → `CBuildingObj::find_building_transit_cells @ 0x6B5230` (pc:701214, VERIFIED): for each building portal, `other = CBldPortal::GetOtherCell(portal)` (`= CEnvCell::GetVisible(other_cell_id)`, pc:325003), and if non-null, `CEnvCell::check_building_transit(other, portal->other_portal_id, ...)` (pc:701227). `CEnvCell::check_building_transit @ 0x52C5D0` (pc:309827, VERIFIED): if the sphere intersects the interior cell's `structure` (`sphere_intersects_cell != OUTSIDE`), it `add_cell`s the interior EnvCell and sets `sphere_path->hits_interior_cell = 1` (pc:309857-309860). **This is the outdoor→indoor entry**: standing outside, when your foot sphere pokes through a building's door portal, the interior cell joins the candidate set, the directional picker (A3.2) prefers it (interior wins), and `curr_cell` advances into the building on the next successful sub-step. `CSortCell : CObjCell { CBuildingObj* building }` (`acclient.h:31880-31883`); `CBuildingObj : CPhysicsObj { num_portals; CBldPortal** portals; num_leaves; CPartCell** leaf_cells; ... }` (`acclient.h:31908-31916`). ### A4.3 indoor→outdoor (exit) resolution at set-position time `CPhysicsObj::AdjustPosition @ 0x511D80` (pc:280009, VERIFIED) is the *initial* cell resolver used by `SetPositionInternal(Position,…)`. For an indoor id it: 1. `eax_5 = CObjCell::GetVisible(objcell_id)`. 2. `eax_6 = CEnvCell::find_visible_child_cell(eax_5, globalPoint, arg5)` (pc:280028) — find the exact child cell containing the point (via stab list or portals). 3. If found → use it (pc:280032). 4. If **not** found AND `eax_5->seen_outside != 0` (pc:280037) → `Position::adjust_to_outside(arg1)` (pc:280039) and `GetVisible(outsideId)` — **the indoor→outdoor exit**: when the point is no longer in any reachable interior child cell and the cell can see outside, convert to the outdoor landcell. `CObjCell::seen_outside` (`acclient.h:30929`, VERIFIED) is the per-cell flag "this cell has an exterior portal / can reach the open world." `check_other_cells` has the mid-sweep version of the same exit (pc:272772-272795): when no candidate cell contains the swept center and the id < 0x100 path applies, it calls `LandDefs::adjust_to_outside` and resets `check_cell = null` with the outdoor id, letting the next sub-step land in the outdoor landcell. ## A5. Is the cell ARRAY the same as `curr_cell`? — No, they're two things, related per-transition - **`curr_cell` (and committed `CPhysicsObj::cell`)** = *membership* — the single answer to "which cell am I in." One pointer. Advanced only by `validate_transition`. - **`CELLARRAY` (`CTransition::cell_array`)** = *the collision candidate set* — every cell whose BSP/geometry the swept sphere must be tested against this sub-step (the current cell + portal neighbors + outdoor landcells if a doorway is straddled + building interiors if a building portal is straddled). Many cells. Rebuilt by `find_cell_list` each time `cell_array_valid == 0`. **How they relate within one transition:** `find_cell_list` does both jobs in one pass — it fills the `CELLARRAY` (for collision) **and** writes the single containing cell into `*arg5` (the membership candidate). `check_other_cells @ 0x50AE50` (pc:272717, VERIFIED) calls `find_cell_list(cell_array, &var_4c, sphere_path)`, collides the sphere against every array cell except the current one (`cell->vtable[+0x88](this)` = `find_collisions`, pc:272735), and on success sets `sphere_path.check_cell = var_4c` (the containing cell, pc:272760-272761). So: **the array drives collision; the picked element (`var_4c`) becomes the next `check_cell`, which `validate_transition` then promotes to `curr_cell`.** Two mechanisms, one shared builder. --- # B. Underground / dungeons ## B6. Representation: dungeons vs building interiors Both dungeons and building interiors are **EnvCell graphs** (`CEnvCell` with `structure`, `portals`, `static_objects`), but they differ in their relationship to the landblock and terrain: - **Building interior (cottage/inn):** the EnvCells sit *on* a landblock that has terrain. They are reached from the open world via a `CBuildingObj`'s `CBldPortal`s (A4.2). Some of their `CCellPortal`s are **exterior portals** (`other_cell_id == 0xFFFF`) — the doorways/windows that see the outdoors. `seen_outside` is **true** for cells with such portals. The landblock's `CLandBlockInfo` (`acclient.h:31893-31905`, VERIFIED) carries `num_cells; cell_ids; CEnvCell** cells;` (the interior cells) **and** `num_buildings; BuildInfo** buildings;` (the buildings) alongside `cell_ownership` and a `restriction_table`. - **Dungeon:** a self-contained EnvCell graph (often its own landblock with the `0x..FF` "all-cells" range) with **no exterior portals** and **no terrain** (`CLandCell` for that landblock is degenerate). `seen_outside` is **false** for dungeon cells. INFER (high confidence): the engine "knows there's no sky/terrain" not via a dedicated underground flag but because the camera cell is an `CEnvCell` whose reachable graph contains **no exit portal** — so `PView`'s `outside_view.view_count` stays 0 and `LScape::draw` is never invoked through a portal (see C). The dat-level distinction is the **absence of exterior portals / `seen_outside == 0`**, not a boolean "underground." `CCellStruct` (the per-cell geometry, `acclient.h:32275-32290`, VERIFIED) carries everything a cell needs: `vertex_array; num_portals; CPolygon** portals; surface_strips; polygons; drawing_bsp; physics_polygons; physics_bsp; cell_bsp;`. Note the **three BSPs**: `drawing_bsp` (render), `physics_bsp` (collision against cell geometry), `cell_bsp` (point/sphere-in-cell containment tests). A dungeon cell and a building interior cell are the *same* struct; only their portal topology and `seen_outside` differ. ## B7. Moving through a dungeon: cell tracking, loading, no-terrain - **Cell tracking** is identical to A1-A3: `transition` → `validate_transition` advances `curr_cell` across `CCellPortal`s (interior→interior, A4.1). The only difference is that no portal is an exterior portal, so `find_transit_cells` never calls `add_all_outside_cells` (its `var_44` flag stays 0). - **Loading/streaming:** `CEnvCell::GetVisible @ 0x52DC10` (pc:311378, VERIFIED) and `CObjCell::GetVisible @ 0x52AD40` (pc:308209, dispatch by id magnitude: ≥0x100 → `CEnvCell::GetVisible`, else `CLandCell::GetVisible`). EnvCells are fetched/built on demand; `CEnvCell::PreFetchCells @ 0x52C460` (pc:309754, VERIFIED) prefetches the cell's `stab_list`-reachable cells **and**, *only if* `seen_outside`, the surrounding landblock (`LScape::PreFetchCells(m_DID.id | 0xFFFF)`, pc:309759). For a dungeon (`seen_outside == 0`) the surrounding landscape is **never prefetched** — confirming there is no terrain to stream/draw. - **No-sky/terrain knowledge:** see B8 + C12. ## B8. Is there an explicit "underground" flag? **Mostly no — it's derived.** I found **no** boolean `is_underground` on `Position`, landblock, or cell. The operative field is `CObjCell::seen_outside` (`acclient.h:30929`, VERIFIED). The render decision (C12) keys on *"is the viewer cell an `CEnvCell`, and does it / its reachable graph have an exterior portal?"*: - `SmartBox::RenderNormalMode @ 0x453AA0` (pc:92649, VERIFIED) computes `ebx_1 = (outdoor_view || viewer_cell->seen_outside)` to decide whether to update the landscape viewpoint at all. - `PView` accumulates exterior-portal clip regions into `outside_view`; if `outside_view.view_count == 0` (no exit portal was visible — i.e., a sealed dungeon), `LScape::draw` is skipped in `DrawCells` (pc:432715, VERIFIED). So "underground" ≡ "current `CEnvCell` reachable graph yields no visible exterior portal," which makes terrain+sky drop out naturally. There is also the cell-id magnitude convention itself: low-16 `>= 0x100` ⇒ this id names an `CEnvCell` (interior), `< 0x100` ⇒ a `CLandCell` (outdoor surface cell of a landblock). This is the *type* discriminator used everywhere (`find_cell_list` pc:308753, `GetVisible` pc:308209), but it does **not** by itself mean "underground" — a cottage interior is `>= 0x100` too. Underground is the further refinement `seen_outside == 0`. --- # C. Rendering inside and outside (the seamless seal) ## C9. The single-pass visible-set build (`ConstructView` / `InitCell` / PView) Retail's interior render is `PView` ("portal view"). The whole thing is **one** portal-flood BFS over the shared `CEnvCell` graph. Top-level entry when the camera is inside a cell: `PView::DrawInside @ 0x5A5860` (pc:433793, VERIFIED): ```c CEnvCell::curr_view_push(arg2); // push this cell's view stack PView::add_views(this, arg2->num_stabs, arg2->stab_list); // pre-push stab-list cells (pc:433801) Render::copy_view(arg2->portal_view.data[num_view-1], null, 4); // seed the camera's view edx_2 = PView::ConstructView(this, arg2, 0xFFFF); // *** build visible set *** PView::DrawCells(this, edx_2); // *** draw it *** PView::remove_views(this, arg2->num_stabs, arg2->stab_list); arg2->num_view -= 1; ``` `PView::ConstructView(CEnvCell, portal_id) @ 0x5A57B0` (pc:433750, VERIFIED) — the BFS: ```c outside_view.view_count = 0; // reset the "outside seen through a portal" accumulator master_timestamp += 1; cell_todo_num = 0; cell_draw_num = 0; InitCell(this, arg2, portal_id); // compute per-portal in/out flags for the start cell InsCellTodoList(this, arg2, 0); // seed the worklist while (cell_todo_num > 0) { cell = pop(cell_todo_list); if (cell == 0) break; cell_draw_list[cell_draw_num++] = cell; // add to OUTPUT cell->portal_view[num_view-1]->cell_view_done = 1; if (ClipPortals(this, cell, 0)) // clip this cell's portals to the view AddViewToPortals(this, cell); // enqueue visible neighbor cells } ``` `PView::InitCell @ 0x5A4B70` (pc:432896, VERIFIED): for each portal of the cell, it computes the portal plane's side relative to the camera viewpoint (`Render::FrameCurrent->viewer.viewpoint`, pc:432935-432962), sets the portal's `seen`/`inflag` state in `portal_view`, and chooses the relevant `portal_side`. This is the per-portal visibility/side determination. `PView::AddViewToPortals @ 0x5A52D0` (pc:433446, VERIFIED): walks the cell's portals; for each portal whose `other_cell` exists and is flagged visible, it `InitCell`s the neighbor and `InsCellTodoList`s it (pc:433480-433485) — enqueuing the neighbor into the BFS — and `SetOtherSeen` (pc:433490). This is the recursive portal traversal: visibility flows cell→neighbor only through portals the camera can see through. **Output:** `cell_draw_list[0..cell_draw_num]` = the ordered list of visible `CEnvCell`s, each with a per-portal **clip region** stored in its `portal_view` (`CEnvCell.num_view` / `portal_view`, `acclient.h:32089-32090`), plus `outside_view` = the accumulated exterior-portal clip region(s). **acdream parallel (already present):** `CellVisibility.GetVisibleCellsFromRoot` (`CellVisibility.cs:539`) is the same portal BFS — a `Queue`, per-portal `InsideSide`/clip-plane test (`CellVisibility.cs:577-589`, "Source: ACME EnvCellManager.cs lines 1458-1459"), exit-portal detection (`portal.OtherCellId == 0xFFFF → HasExitPortalVisible = true`, `CellVisibility.cs:561-565`). So acdream's render already mirrors retail's `ConstructView`; what's missing is consuming the result correctly + drawing the outside through the portal (C10) and rooting it at the physics cell (C13/D). ## C10. Drawing the OUTSIDE through a doorway/window (no blue clear-color hole) This is the crux. Two pieces: **(1) Exit portals contribute a clip region to `outside_view`.** Inside `PView::ClipPortals @ 0x5A5520` (pc:433572, VERIFIED), when iterating a cell's portals, the branch at pc:433662-433685 handles a portal whose `other_cell` id is `0xFFFFFFFF` (an exterior portal): ```c if (*esi_3 == 0xFFFFFFFF) { // EXTERIOR portal if (this->draw_landscape != 0) { // PView built with draw_landscape=true if (cliplandscape != 0) Render::copy_view(this/*->outside_view*/, &clip_view, ecx_8); else if (draw_landscape) Render::copy_view(this/*->outside_view*/, null, 0); } } ``` i.e., the exterior portal's **screen clip region** (`clip_view`, computed by `GetClip`) is copied into the PView's `outside_view`. The `draw_landscape` flag is set at PView construction (`PView::PView @ 0x5A5270` pc:433441: `this->draw_landscape = arg2;`, VERIFIED) — the *indoor* PView is built with `draw_landscape = true` so doorways always feed the landscape view. **(2) `DrawCells` renders the landscape clipped to that region.** `PView::DrawCells @ 0x5A4840` (pc:432709, VERIFIED) opens with: ```c if (this->outside_view.view_count > 0) { // an exit portal was visible Render::useSunlightSet(1); Render::PortalList = this; // tell LScape to clip to outside_view LScape::draw(this->lscape); // *** draw terrain + sky + exterior, clipped *** D3DPolyRender::FlushAlphaList(0); ... if (forceClear || portalsDrawnCount==0) // clear-color ONLY if nothing was drawn RenderDevice::Clear(4, 0x820fc0, ...); // (pc:432731-432732) ... draw interior cells' surfaces (drawing_bsp), then portals ... } ``` So the outdoors (terrain, sky, rain, exterior buildings) is drawn by `LScape::draw @ 0x506330` (pc, VERIFIED address) **with `Render::PortalList` set to the PView**, which clips it to the union of exit-portal screen regions. The result: through a cottage doorway you see the actual world (sky/rain), not a clear-color hole. **The blue clear-color only appears if `portalsDrawnCount == 0`** — i.e., if the portal machinery produced nothing (a truly sealed cell, or a bug). **Positioning the outside correctly:** before `DrawInside`, `RenderNormalMode` (pc:92667-92670, VERIFIED) does `if (ebx_1 /*seen_outside*/) { eax_1 = Position::get_outside_cell_id(&viewer); LScape::update_viewpoint(lscape, eax_1); }`. `Position::get_outside_cell_id @ 0x4527B0` (pc:91552, VERIFIED) converts the indoor camera position to the outdoor landcell id via `LandDefs::adjust_to_outside`. So the landscape is centered on the landblock the building sits in, ready to be drawn through the doorway. `PView::GetClip @ 0x5A4320` (pc:432344, VERIFIED) is the clip-region builder: it projects the portal poly's vertices to screen (`PrimD3DRender::xformStart`) and runs `ACRender::polyClipFinish` to produce the 2D clip polygon, honoring `Sidedness` (front/back of the portal). **The exterior→interior recursion (camera OUTSIDE looking into a building):** `PView::ConstructView(CBldPortal, CPolygon portal, …) @ 0x5A59A0` (pc:433827, VERIFIED) is the mirror image — reached via `PView::DrawPortal @ 0x5A5AB0` (pc:433895) while drawing the landscape. It side-tests the building portal poly against the camera, `GetClip`s it, and if the interior is visible recurses `ConstructView(this, other_cell, other_portal_id)` (pc:433879) to draw the building's interior cells through the open door, clipped to the door's screen region. So **both directions are the same portal mechanism**: outside↔inside is seamless because it's literally one recursive portal-clipped traversal across the shared cell graph. ## C11. Sealing interiors (ceilings capped, no bleed, entities clipped) - **Walls/ceilings are capped because each visible cell draws its own closed geometry.** `DrawCells` (pc:432745-432802, VERIFIED) draws each `cell_draw_list` cell's surfaces using `cell->structure->drawing_bsp` and `Render::SetSurfaceArray(cell->surfaces)`, per portal-view (`CEnvCell::setup_view` per `view_count`). An EnvCell's geometry is a closed box (floor, 4 walls, ceiling) authored in the dat; the `drawing_bsp` orders/back-face-culls it. There is no "open top" — the ceiling polygon is part of the cell's surface array. So standing inside, the ceiling is present by construction. - **No outdoor bleed-in** because the outdoor world is only drawn *through* exit-portal clip regions (`outside_view`), never full-screen, when the camera cell is interior. The interior cells are drawn after / composited with the clipped landscape. The `Clear(4,…)` (depth/region clear) only fires where nothing was drawn. - **Entities/particles clipped to visible cells:** the final loop of `DrawCells` (pc:432868-432882, VERIFIED) iterates `cell_draw_list` and for each calls `DrawObjCellForDummies(cell)` with `Render::PortalList` set to that cell's portal view — i.e., objects are drawn per-cell, clipped to that cell's visible portal region. An object in a non-visible cell is never in `cell_draw_list`, so it isn't drawn; an object straddling a portal is clipped to the portal opening. (Object→cell membership comes from the physics `enter_cell`/`leave_cell` shadow lists — the *same* cell graph; see C13.) ## C12. Terrain + sky vs not, as a function of current cell The decision tree (`SmartBox::RenderNormalMode @ 0x453AA0`, pc:92635-92684, VERIFIED), per frame: ``` viewer_cell = SmartBox::update_viewer's result (see C13) outdoor_view = (viewer_cell is a LandCell / id < 0x100, OR static_camera special-case) // "edi_2" ebx_1 = outdoor_view || viewer_cell->seen_outside if (outdoor_view) { // camera is OUTSIDE LScape::update_viewpoint(lscape, viewer.objcell_id); Render::update_viewpoint(&viewer); Render::set_default_view(); Render::useSunlightSet(1); LScape::draw(lscape); // FULL terrain + sky (+ recurse into buildings) } else { // camera is INSIDE an EnvCell if (ebx_1 /*seen_outside*/) // interior that can see out: LScape::update_viewpoint(lscape, Position::get_outside_cell_id(&viewer)); // pre-position terrain Render::update_viewpoint(&viewer); RenderDevice::DrawInside(viewer_cell); // PView portal traversal; terrain only through exits } ``` So: - **Outdoor cell (`< 0x100`):** full landscape + sky drawn unconditionally (`LScape::draw`). Buildings are recursed into via `CBldPortal` portals during the landscape draw. - **Interior cell with `seen_outside`** (cottage/inn): `DrawInside` (interior cells), and the landscape is drawn **only** through visible exit portals (C10). Sky/rain appears in the doorway, not full-screen. - **Interior cell without `seen_outside`** (dungeon): `DrawInside`, `outside_view.view_count` stays 0, `LScape::draw` is never reached, so **no terrain, no sky** — exactly what a dungeon needs. `RenderDeviceD3D::DrawInside @ 0x59F0D0` (pc:427843, VERIFIED) just forwards: `PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2)`. ## C13. Is render's cell the SAME as physics's `curr_cell`? — YES (this is the central finding) **VERIFIED, conclusively.** The render's camera cell is produced by `SmartBox::update_viewer @ 0x453CE0` (pc:92761), which: 1. Starts from `player->cell` (the physics-committed cell, pc:92836/92842 — `cell = this->player->cell`). 2. Builds a *camera* transition (`CTransition::makeTransition` + `init_object(player, 0x5c)` + `init_sphere(1, &viewer_sphere, 1)` + `init_path(cell_1, desired_cam_pos, …)`, pc:92860-92866). This is the camera **spring-arm / collision** sweep — the camera is swept from the player toward the desired chase position and stopped on geometry (the `SmartBox::update_viewer` spring arm acdream already ported). 3. On `find_valid_position` success: `SmartBox::set_viewer(this, &eax_8->sphere_path.curr_pos, 0); this->viewer_cell = eax_8->sphere_path.curr_cell;` (pc:92870-92871). **The render's camera cell is the `curr_cell` tracked through that transition — the exact same `validate_transition` mechanism physics uses for the player.** 4. Fallbacks: if the camera transition fails, `AdjustPosition(&var_120, &viewer_sphere, &var_170, …)` (pc:92878) resolves the cell statically and uses `var_170` (pc:92881); last resort `viewer_cell = null` (pc:92887). So render does **not** maintain an independent cell graph. It traverses the **same** `CEnvCell`/`CCellPortal` graph that physics uses, and it derives the camera cell from a transition's `sphere_path.curr_cell` — exactly like the player. The object→cell associations that clip entities (C11) come from the physics `enter_cell`/`leave_cell` shadow lists. One graph, one membership concept, two consumers (player movement and camera). **Contrast with acdream:** acdream's render runs `CellVisibility.FindCameraCell(cameraPos)` (`CellVisibility.cs:389`, "Ported from ACME EnvCellManager.cs FindCameraCell()") — an **independent** static camera-cell resolver — and a separate `VisibilityResult`. The W2 work added `ComputeVisibilityFromRoot` that *can* take the physics CurrCell as root (`CellVisibility.cs:534`), which is the right direction, but the default path still resolves the camera cell on its own. This is precisely the "render maintains its own cell/visibility system separate from physics" divergence the brief calls out. --- # D. Synthesis for acdream ## D14. The retail-faithful target architecture **One cell-membership value, carried through the sweep, shared by physics and render.** 1. **Physics membership = the swept `curr_cell`.** Stop re-deriving the cell from the static origin. The cell the player is in is whatever the per-tick transition's `sphere_path.curr_cell` (acdream: `SpherePath.CurCellId`) ended at — committed via a `change_cell`-style "only on difference" setter. `validate_transition` already advances it on OK+moved and reverts it on block/standstill; that is the only place membership should change. 2. **`do_not_load_cells` prune for set-position paths.** Port the prune into `find_cell_list`, gated on a `SetPositionStruct.flags & 0x20` analogue, so authoritative/teleport sets cannot drift the cell. (For free movement the prune is *not* needed — accept-on-move + the directional picker suffice. Do **not** keep the ad-hoc `DoorwayHoldMargin` hysteresis; it is a symptom-masking workaround of the static re-derive and is forbidden by the project's no-workarounds rule once the real mechanism lands.) 3. **Render obeys the physics cell + one portal-visibility traversal.** The render's root cell should be the physics `CurrCell` (the camera-cell case is a *second* transition that tracks its own `curr_cell`, but both come from the shared graph and the shared `validate_transition`). `ComputeVisibilityFromRoot` already exists; make it the default and feed it the physics answer. Draw the outside through exit portals (`HasExitPortalVisible` → clip the landscape to the portal region), instead of leaving a clear-color hole. ## D15. Specifically: should membership advance inside the sweep, and should render obey it? **Yes to both, and the decomp is unambiguous:** - **Advance membership inside the sweep (drop the static re-derive).** Retail's `SetPositionInternal(CTransition)` reads `arg2->sphere_path.curr_cell` and commits via `change_cell`-on-difference (pc:283403-283456). acdream must do the same: `ResolveWithTransition` should return `sp.CurCellId` (the swept, accept-on-move cell), **not** `ResolveCellId(sp.GlobalSphere[0].Origin, …)`. The static re-derive (`PhysicsEngine.cs:909/928`) is the flicker source — it independently re-evaluates the boundary against a ±8 cm jittering origin every tick. Because `ValidateTransition` already sets `sp.CurCellId = sp.CheckCellId` (`TransitionTypes.cs:3408`) and the indoor cell-array picker already retargets `sp.CheckCellId` to the containing cell mid-sweep (`TransitionTypes.cs:2074-2075`), the swept answer is already computed and stable — it's simply discarded. **This is the single highest-leverage change and it is small.** *Critical caution:* this touches the collision sweep, where acdream has a long bug history (the #98 saga, ~10 failed fixes). The change itself does not modify collision response — it changes only *which already- computed cell id is returned*. Keep the collision math byte-for-byte; change only the return value and the consumer in `PlayerMovementController`/`GpuWorldState` that reads `ResolveResult.CellId`. - **Render obeys the physics `curr_cell` + single portal traversal.** Retail derives `viewer_cell` from a transition's `curr_cell` (pc:92871) and builds the visible set with one `ConstructView` BFS (pc:433750). acdream should root `CellVisibility` at the physics `CurrCell` answer (W2's `ComputeVisibilityFromRoot`) rather than a separate `FindCameraCell`, and render the outside through exit portals. Justification: a separate render cell system is exactly what produces the threshold strobe (render cell and physics cell disagree by a frame) and the doorway clear-color hole (render never wires the exit portal to the landscape). ## D16. Must-port functions, integration order, risks, conformance tests ### Must-port / must-align functions (with retail addresses) **Physics — membership (the core):** | Retail fn | Addr | pc:LINE | acdream status | |---|---|---|---| | `CTransition::validate_transition` | `0x50AA70` | 272547 | Present (`TransitionTypes.cs:3398`), advances `CurCellId` ✔ | | `CPhysicsObj::SetPositionInternal(CTransition)` | `0x515330` | 283399 | **Missing the read** — `ResolveWithTransition` discards `sp.CurCellId` | | `CPhysicsObj::change_cell` | `0x513390` | 281192 | Conceptually present (cell-id assignment); ensure "only on change" | | `CObjCell::find_cell_list` (master) | `0x52B4E0` | 308742 | Partial (`CellTransit.FindCellList`); **needs the `do_not_load_cells` prune** + directional picker semantics | | `CObjCell::find_cell_list` (sweep fwd) | `0x52B960` | 309085 | via `CellTransit.FindCellSet` | | `CTransition::check_other_cells` | `0x50AE50` | 272717 | Present (`TransitionTypes.cs` CheckOtherCells), retargets `CheckCellId` ✔ | | `CEnvCell::find_transit_cells` | `0x52C820` | 309968 | Present (portal expansion + outside add) | | `CEnvCell::point_in_cell` | `0x52C300` | 309677 | Present (CellBSP point test) | | `CEnvCell::check_building_transit` | `0x52C5D0` | 309827 | Present (`CellTransit.CheckBuildingTransit`) | | `CLandCell::add_all_outside_cells` | `0x533630` | 317499 | Present (`AddAllOutsideCells`) — verify `added_outside` once-guard | | `CPhysicsObj::AdjustPosition` | `0x511D80` | 280009 | Use for **initial** cell resolution only (teleport/login), not per-tick | | `CEnvCell::find_visible_child_cell` | `0x52DC50` | 311397 | Present (`CellVisibility`/ACE port) — for initial resolve + exit detection | **Render — seamless seal:** | Retail fn | Addr | pc:LINE | acdream status | |---|---|---|---| | `SmartBox::RenderNormalMode` | `0x453AA0` | 92635 | The indoor/outdoor decision tree to mirror | | `SmartBox::update_viewer` | `0x453CE0` | 92761 | Spring-arm ported; **also set render root = transition `curr_cell`** | | `PView::DrawInside` | `0x5A5860` | 433793 | acdream `GetVisibleCellsFromRoot` is the BFS analogue | | `PView::ConstructView(CEnvCell)` | `0x5A57B0` | 433750 | Portal BFS ✔ (mirror exists) | | `PView::ConstructView(CBldPortal)` | `0x5A59A0` | 433827 | Exterior→interior recursion (outside-looking-in) — **not yet** | | `PView::ClipPortals` | `0x5A5520` | 433572 | **Exit-portal→`outside_view`** copy is the missing seam | | `PView::DrawCells` | `0x5A4840` | 432709 | **`outside_view>0 ⇒ LScape::draw` clipped** + per-cell object clip | | `PView::GetClip` | `0x5A4320` | 432344 | Portal screen-clip builder | | `LScape::update_viewpoint` / `Position::get_outside_cell_id` | `0x5062D0` / `0x4527B0` | — / 91552 | Pre-position terrain for doorway draw | ### Integration order (lowest-risk first) 1. **Membership return fix (physics).** Change `ResolveWithTransition` to return `sp.CurCellId` (the swept cell) instead of `ResolveCellId(origin,…)`. Delete the `DoorwayHoldMargin`/sphere-overlap hysteresis in `ResolveCellId` *only after* this lands clean (it becomes dead). Add the `change_cell`-on-difference setter semantics so the W2 `CellGraph.CurrCell` writer fires only on actual change. **Verify the flicker is gone with `ACDREAM_PROBE_CELL` (one `[cell-transit]` per real cell change — should be ~1 at the doorway, not 20+/sec).** This is the keystone; do it alone, verify, commit. 2. **`do_not_load_cells` prune (physics, set-position only).** Add the flag to the cell-array build, set it on authoritative/teleport set-position, port the prune loop from `find_cell_list` (pc:308829-308867 / ACE `ObjCell.cs:387-413`). Confirms constrained sets don't drift the cell. Conformance test below. 3. **Render root = physics cell.** Make `CellVisibility` default to `ComputeVisibilityFromRoot(physics CurrCell)` (camera-cell variant for 3rd person tracks its own viewer transition, but rooted in the same graph). Remove the independent `FindCameraCell` default once verified. Kills the threshold strobe. 4. **Draw the outside through exit portals (render).** When `HasExitPortalVisible`, clip the landscape draw to the exit-portal screen region (`GetClip` analogue) and draw terrain+sky there, pre-positioned via `get_outside_cell_id`/`update_viewpoint`. Removes the blue clear-color hole; caps the dungeon (no exit portal ⇒ no landscape). Mirror `PView::DrawCells`'s `outside_view>0 ⇒ LScape::draw` gate. 5. **(Optional/last) exterior→interior recursion** (`ConstructView(CBldPortal)`) for "outside looking into a building," if not already covered by the landscape→building portal path. ### Main risks - **Touching the collision sweep.** The membership-return fix is *adjacent* to the sweep but changes no collision math — keep it that way. Do not "improve" `find_cell_list` or `check_other_cells` while in there. The #98 saga proves speculative sweep edits regress. Land step 1 in isolation, verify, commit before step 2. - **`change_cell`-on-difference must be exact.** If acdream commits the cell unconditionally (even when equal) it could re-fire `enter_cell`/`leave_cell` side-effects (shadow-list churn) every tick — verify the setter early-returns on `this->cell == newCell` (retail pc:283414). - **The directional picker must prefer interior cells.** If acdream's `FindCellList` returns the *first* containing cell regardless of type (instead of "first **interior** containing cell wins, break"), the threshold can still pick outdoors. Match pc:308814-308819 / ACE `ObjCell.cs:378-382` exactly. - **Render root timing.** The render must read the *current frame's* committed physics cell (after the physics tick), not a stale one, or the strobe just moves. Order: physics tick → commit `CurrCell` → camera viewer transition → render BFS. - **Dungeon vs cottage must both work from one path.** The same code must seal a dungeon (no exit portal ⇒ no terrain) and a cottage (exit portal ⇒ terrain through doorway). Test both. ### Conformance tests that would prove faithfulness 1. **Standing-still cell stability (the flicker test).** Place the player at the cottage threshold (the `0xA9B40170 ↔ 0xA9B40031` spot), run N≥120 physics ticks with zero input. Assert `CurrCell` changes **0** times after the initial settle (retail: `validate_transition`'s no-move branch never promotes). This is the direct regression guard for the bug. 2. **Doorway crossing is monotone.** Walk slowly outdoor→vestibule→room and back. Assert the `[cell-transit]` sequence is a clean monotone chain (`0031 → 0170 → 0157 …` then reverse) with exactly one transition per real boundary crossing — no oscillation, no skipped cells. 3. **`validate_transition` accept/revert unit test.** Drive `ValidateTransition` with (a) OK+moved → assert `CurCellId == CheckCellId`; (b) OK+not-moved → assert `CurCellId` unchanged; (c) Collided/Slid → assert `CurCellId` unchanged and `CheckPos == CurPos`. Mirrors pc:272593/272600/272612 and ACE `Transition.cs`. 4. **`find_cell_list` directional picker + prune.** Synthetic cell set where the foot sphere overlaps both an interior cell and the outdoor landcell: assert the picked containing cell is the **interior** one. With `do_not_load_cells` set and a stranger cell present (not current, not in stab list): assert it's removed from the array; current cell and stab-list cells retained. (Port from ACE `ObjCell.cs` golden behavior.) 5. **Building entry/exit.** From outdoors, walk into a cottage door: assert `CurrCell` advances to the interior EnvCell when the foot sphere crosses the `CBldPortal` (via `CheckBuildingTransit`). From inside, walk out: assert `CurrCell` returns to the outdoor landcell via the `seen_outside`/`adjust_to_outside` exit. 6. **Render seal (visual + assertion).** Standing in the cottage facing the open door: assert the visible-set build reports `HasExitPortalVisible == true` and that the landscape is drawn (no clear-color region in the doorway). Standing in a sealed dungeon cell: assert `HasExitPortalVisible == false` and **no** terrain/sky draw call. (The first is the "see rain through the door" target; the second is the "dungeon has no sky" target.) 7. **Render cell == physics cell.** After a physics tick, assert `CellVisibility` root cell id == player's committed `CurrCell` id (no independent re-resolve divergence). --- ## Appendix: struct field anchors (verbatim from `acclient.h`, VERIFIED) - `SPHEREPATH` — `acclient.h:32625-32671` (curr_cell:32641, check_cell:32647, hits_interior_cell:32655, cell_array_valid:32666, num_sphere:32627, global_curr_center:32635). - `CELLARRAY` — `acclient.h:31574-31580` (added_outside, do_not_load_cells, num_cells, cells). - `CELLINFO` — `acclient.h:31925-31929` (cell_id, cell). - `CObjCell` — `acclient.h:30915-30932` (pos, num_objects/object_list, num_shadow_objects/shadow_object_list, restriction_obj, num_stabs/stab_list:30927-30928, **seen_outside:30929**, myLandBlock_). - `CSortCell : CObjCell` — `acclient.h:31880-31883` (building). - `CLandCell : CSortCell` — `acclient.h:31886-31890` (polygons, in_view). - `CEnvCell : CObjCell` — `acclient.h:32072-32091` (structure, num_portals/portals, num_static_objects/ static_objects, light_array, **num_view/portal_view:32089-32090**). - `CCellStruct` — `acclient.h:32275-32290` (portals(CPolygon**), polygons, **drawing_bsp/physics_bsp/cell_bsp**). - `CCellPortal` — `acclient.h:32300-32308` (other_cell_id, other_cell_ptr, portal, portal_side, other_portal_id, exact_match). - `CBldPortal` — `acclient.h:32094-32103` (portal_side, other_cell_id, other_portal_id, exact_match, num_stabs/stab_list, sidedness). - `CBuildingObj : CPhysicsObj` — `acclient.h:31908-31916` (num_portals/portals, num_leaves/leaf_cells, shadow_list). - `CLandBlockInfo` — `acclient.h:31893-31905` (num_objects/object_ids/object_frames, num_buildings/buildings, restriction_table, cell_ownership, num_cells/cell_ids/cells). ## Appendix: address index (all VERIFIED in `symbols.json` + pseudo-C) Physics: `change_cell` 0x513390 · `SetPositionInternal(CTransition)` 0x515330 · `SetPositionInternal(Position,SetPositionStruct,CTransition)` 0x515BD0 · `validate_transition` 0x50AA70 · `validate_placement_transition` 0x50ADC0 · `check_collisions` 0x50AA00 · `check_other_cells` 0x50AE50 · `transitional_insert` 0x50B6F0 · `find_transitional_position` 0x50BDF0 · `find_valid_position` 0x50C310 · `init_path`(SPHEREPATH) 0x50CE20 · `find_cell_list`(master) 0x52B4E0 · `find_cell_list`(sweep fwd) 0x52B960 · `CObjCell::GetVisible` 0x52AD40 · `CEnvCell::GetVisible` 0x52DC10 · `CLandCell::GetVisible` 0x52DB0(→get_landcell) · `CEnvCell::find_transit_cells` 0x52C820 · `CLandCell::find_transit_cells` 0x533800 · `CSortCell::find_transit_cells` 0x534060 · `CEnvCell::point_in_cell` 0x52C300 · `CLandCell::point_in_cell` 0x532D40 · `CEnvCell::check_building_transit` 0x52C5D0 · `CLandCell::add_all_outside_cells` 0x533630 · `CLandCell::find_collisions` 0x532D60 · `CBuildingObj::find_building_transit_cells` 0x6B5230 · `CBuildingObj::find_building_collisions` 0x6B5300 · `CCellPortal::GetOtherCell` 0x53BA30 · `CBldPortal::GetOtherCell` 0x53BC30 · `AdjustPosition` 0x511D80 · `CheckPositionInternal` 0x511E90 · `find_visible_child_cell` 0x52DC50. Render: `SmartBox::RenderNormalMode` 0x453AA0 · `SmartBox::update_viewer` 0x453CE0 · `RenderDeviceD3D::DrawInside` 0x59F0D0 · `PView::DrawInside` 0x5A5860 · `PView::ConstructView(CEnvCell)` 0x5A57B0 · `PView::ConstructView(CBldPortal)` 0x5A59A0 · `PView::InitCell` 0x5A4B70 · `PView::ClipPortals` 0x5A5520 · `PView::AddViewToPortals` 0x5A52D0 · `PView::DrawCells` 0x5A4840 · `PView::GetClip` 0x5A4320 · `PView::AddToCell` 0x5A4D90 · `PView::OtherPortalClip` 0x5A5400 · `LScape::draw` 0x506330 · `LScape::update_viewpoint` 0x5062D0 · `Position::get_outside_cell_id` 0x4527B0.