# Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering > **Independent decomp study (model: Opus 4.8, variant A). 2026-06-02.** > Primary oracle: the named retail decomp at `docs/research/named-retail/` > (`acclient_2013_pseudo_c.txt`, `acclient.h`, `symbols.json`). Cross-checked > against `references/ACE` (C# physics port), `references/ACViewer` (MonoGame > viewer), `references/WorldBuilder`. Citations are `function @ 0xADDR > (pseudo_c:LINE)` for decomp and `repo/path:LINE` for references. Each > non-trivial claim is tagged **[VERIFIED]** (read in source) or **[INFERRED]**. --- ## TL;DR (headline findings) 1. **Retail never re-derives the cell from the final XYZ.** It *tracks* the current cell (`sphere_path.curr_cell`) **through the collision sweep**. The advance happens in `CTransition::validate_transition` on an accepted sub-step (`curr_cell = check_cell`), and a **blocked/standing-still sub-step explicitly resets `check_cell` back to `curr_cell`** — so a push-back can never change which cell you are in. At the end of the tick, `CPhysicsObj::SetPositionInternal(CTransition*)` reads `transition.sphere_path.curr_cell` and calls `change_cell` **only when it differs** from the held cell. This is the structural cure for the flicker. 2. **`find_cell_list` builds a candidate cell array (containment + portal neighbours), picks the *single* containing cell — interior cells win and short-circuit — and then applies the `do_not_load_cells` prune** which removes every candidate that is neither the current cell nor in the current interior cell's static visibility list (`stab_list`). The prune is what buys stability for interior membership: it stops far/irrelevant cells from ever becoming the answer. 3. **Underground/dungeons are NOT a special mode.** There is no "underground" flag in the retail client physics or render path. A dungeon is simply a landblock whose terrain heights are all zero, which has ≥1 EnvCell and no buildings — so you are always inside an `EnvCell` whose `seen_outside == 0`, and the renderer therefore never draws terrain or sky. "Inside a building" differs only in that the EnvCell sits on a real terrain landblock and (often) has `seen_outside == 1`, so the landscape is still drawn through the door. 4. **Retail renders inside+outside in ONE portal-visibility traversal (`PView`).** `SmartBox::RenderNormalMode` makes a single decision per frame from `viewer_cell` (an outdoor landcell → draw the landscape; an EnvCell → `DrawInside`). `PView::ConstructView` does a breadth-first portal walk that produces an ordered visible cell list (`cell_draw_list`) plus per-portal screen clip regions. When a portal's `other_cell_id == 0xffffffff` (an exit portal to the outdoors) it pulls the **landscape into the same traversal, clipped to the doorway**, so there is no blue clear-color hole; sky/rain/ terrain show through. The depth clear is conditional, not an unconditional blue fill. 5. **Render and physics use the SAME cell graph and the SAME `objcell_id`.** The render-side `CellManager::ChangePosition` resolves its `curr_cell` via `CObjCell::Get(position.objcell_id)` — the very same id physics maintains. `seen_outside` is a per-cell dat flag (`EnvCellFlags.SeenOutside = 0x1`). Both the physics cell array and the render PVS traverse cells via the same `CEnvCell::portals` / `stab_list` / `GetVisible` machinery (just different BSP trees inside the same `CCellStruct`: `physics_bsp`/`cell_bsp` vs `drawing_bsp`). 6. **acdream's bug is localized and the fix surface already exists.** acdream's `SpherePath` already tracks `CurCellId`/`CheckCellId` and its `ValidateTransition` already advances/resets them correctly (`TransitionTypes.cs:3398-3434`). The *only* defect is at the engine output: `ResolveWithTransition` (`PhysicsEngine.cs:909` and `:928`) throws away `sp.CurCellId` and **re-derives the cell statically** via `ResolveCellId(sp.GlobalSphere[0].Origin, …)`. Plus there is no `do_not_load_cells` prune in `CellTransit`, and the render maintains a *separate* cell system (`CellVisibility.FindCameraCell` with a 3-frame grace-frame band-aid). The unified W1 `CellGraph` (`CurrCell` + `GetVisible`) is the seam to land all three fixes on. --- # A. Cell membership & transitions (physics) ## A0. The data model: `SPHEREPATH` and `CObjCell` The single source of truth for "the cell I'm in" during a move is `SPHEREPATH::curr_cell`. The struct (verbatim retail header) is: `acclient.h:32625` — `struct SPHEREPATH` **[VERIFIED]**: ```c struct SPHEREPATH { ... CObjCell *begin_cell; // where the sweep starts (== object's current cell) Position *begin_pos; Position *end_pos; // requested destination CObjCell *curr_cell; // ACCEPTED cell — the committed membership answer Position curr_pos; // ACCEPTED position ... CObjCell *check_cell; // CANDIDATE cell being tested this sub-step Position check_pos; // CANDIDATE position SPHEREPATH::InsertType insert_type; // TRANSITION_INSERT / PLACEMENT_INSERT ... CObjCell *backup_cell; // saved for restore (step-up/walkable probes) Position backup_check_pos; int hits_interior_cell; // set when an interior cell was added/contained int cell_array_valid; ... }; ``` The cell objects themselves (`acclient.h:30915` `CObjCell`, `:32072` `CEnvCell`, `:31886` `CLandCell`) **[VERIFIED]**: ```c struct CObjCell : SerializeUsingPackDBObj, CPartCell { LandDefs::WaterType water_type; Position pos; // cell-to-world frame ... unsigned int num_shadow_objects; DArray shadow_object_list; // collision objects registered in this cell unsigned int restriction_obj; ... unsigned int num_stabs; unsigned int *stab_list; // STATIC visibility set: cell ids this cell can see int seen_outside; // boolean: this interior can see the outdoors ... CLandBlock *myLandBlock_; }; struct CEnvCell : CObjCell { ... CCellStruct *structure; // geometry: vertices, polys, 3 BSP trees unsigned int num_portals; CCellPortal *portals; // portal graph edges unsigned int num_static_objects; ... unsigned int num_view; DArray portal_view; // RENDER per-portal clip state }; struct CLandCell : CSortCell { // CSortCell carries `building` (CBuildingObj*) CPolygon **polygons; BoundingType in_view; }; ``` Key takeaways for membership: - **`curr_cell` ≠ "the cell array".** `curr_cell` is *one* cell (membership). `cell_array` is the multi-cell set the collision sweep queries (see A5). - **`stab_list`** is a precomputed per-cell visibility list (the "stabs"). It is used by both the physics prune (A2) and the render PVS (C9). - **`seen_outside`** is a per-cell boolean meaning "this interior cell has an exterior portal / can see outdoors." (Confirmed as dat flag `EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7`). **[VERIFIED]** ## A1. The full membership chain (per tick) The per-tick movement flows: `UpdateObjectInternal → transition → find_valid_position → find_transitional_position → (loop) transitional_insert + validate_transition → SetPositionInternal → change_cell`. ### Step 1 — seed `curr_cell` from the object's current cell `CPhysicsObj::transition @ 0x00512dc0 (pseudo_c:280904)` **[VERIFIED]** builds a fresh `CTransition`, then at `:280939`: ```c CTransition::init_path(result, this->cell, arg2 /*m_position*/, arg3 /*newPos*/); ``` `init_path → SPHEREPATH::init_path @ 0x0050ce20 (pseudo_c:274359)` **[VERIFIED]** seeds: ```c this->begin_cell = arg2; // == CPhysicsObj.cell ... this->curr_pos.objcell_id = arg3->objcell_id; // begin pos this->curr_cell = arg2; // curr_cell STARTS as the held cell (:274370) this->insert_type = TRANSITION_INSERT; ``` So the sweep *begins* anchored to the cell you are already in. ### Step 2 — the sub-step loop advances `check_pos`, then validates `CTransition::find_transitional_position @ 0x0050bdf0 (pseudo_c:273613)` **[VERIFIED]** is the driver. It computes a step count (`calc_num_steps`), then per sub-step (for `TRANSITION_INSERT`, `pseudo_c:273736-273755`): ```c // advance the CANDIDATE position by the per-step offset this->sphere_path.check_pos.frame.origin += global_offset; // :273739-273741 SPHEREPATH::cache_global_sphere(&sphere_path, &global_offset); var_44 = CTransition::validate_transition(this, CTransition::transitional_insert(this, 3), &var_40); // :273743 ... ebx += 1; if (ebx >= var_48) goto done; continue; ``` At the very end (when all sub-steps are consumed, `pseudo_c:273648-273651`): ```c SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); sphere_path.cell_array_valid = 1; sphere_path.hits_interior_cell = 0; CObjCell::find_cell_list(&this->cell_array, nullptr, &this->sphere_path); // rebuild final cell array ``` **Critical**: the final `find_cell_list` rebuilds the *collision* `cell_array` around the final position, but the **membership answer is already in `curr_cell`** (advanced by `validate_transition` during the loop). The final `find_cell_list` is passed `arg5 = nullptr` (no `*currCell` out-param), so it does NOT re-pick membership. ### Step 3 — `transitional_insert`: one sub-step of collision + cell discovery `CTransition::transitional_insert @ 0x0050b6f0 (pseudo_c:273137)` **[VERIFIED]** is the inner stepper. Per iteration: ```c edi = CTransition::insert_into_cell(this, sphere_path.check_cell, eax_2); // collide in check_cell switch (edi) { case OK_TS: edi = CTransition::check_other_cells(this, sphere_path.check_cell); // discover/cross cells ... } ``` `insert_into_cell @ 0x00509e70 (pseudo_c:271991)` **[VERIFIED]** simply calls `arg2->vtable->find_collisions(this)` (the cell's own collision query). On `OK_TS`, `check_other_cells` runs. ### Step 4 — `check_other_cells`: cross into the right cell mid-sweep `CTransition::check_other_cells @ 0x0050ae50 (pseudo_c:272717)` **[VERIFIED]**: ```c sphere_path.cell_array_valid = 1; sphere_path.hits_interior_cell = 0; CObjCell::find_cell_list(&this->cell_array, &var_4c /*out: containing cell*/, &sphere_path); for (i : cell_array) { // collide against every other cell cell = cell_array.cells[i].cell; if (cell && cell != arg2) result = cell->vtable[+0x88](this); // find_collisions if (result is COLLIDED/ADJUSTED/SLID/CP-clear) handle/return; } check_cell = var_4c; // RETARGET candidate cell to the containing cell if (check_cell != 0) { SPHEREPATH::adjust_check_pos(&sphere_path, check_cell->id); // rebase check_pos id to new cell return result; } // --- no containing cell found: we have left the indoor graph --- if (sphere_path.step_down) return COLLIDED_TS; objcell_id = sphere_path.check_pos.objcell_id; if (objcell_id < 0x100) { /* already outside */ } else LandDefs::adjust_to_outside(objcell_id, &frame); // CONVERT interior id -> outdoor landcell id if (objcell_id == 0) return COLLIDED_TS; SPHEREPATH::adjust_check_pos(&sphere_path, objcell_id); sphere_path.check_cell = nullptr; // will be resolved next find_cell_list ``` This is the **indoor→outdoor exit**: when the foot sphere no longer lands in any portal-connected interior cell, `adjust_to_outside` (`LandDefs::adjust_to_outside @ 0x005a9bc0 (pseudo_c:438719)` **[VERIFIED]**) maps the interior cell id to the surrounding landblock's outdoor cell id, and `check_cell` becomes the landcell on the next pass. ### Step 5 — `validate_transition`: ADVANCE on accept, RESET on block (the anti-flicker) `CTransition::validate_transition @ 0x0050aa70 (pseudo_c:272547)` **[VERIFIED]**. The decisive control flow: ```c if (transitionState != OK || check_pos == curr_pos) // blocked OR no movement { if (transitionState != OK) { if (transitionState != INVALID) { // a real block/slide/adjust ... (contact-plane / last-known-plane handling) ... SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // :272593 // ^^^ RESET candidate back to curr_pos / curr_cell — cell does NOT change CTransition::build_cell_array(this, nullptr); transitionState = OK_TS; } } else { // check_pos == curr_pos: accept-no-move => label_50aba9 advances (no-op) } } else // OK and we moved { label_50aba9: // :272608 check_cell = sphere_path.check_cell; sphere_path.curr_pos.objcell_id = sphere_path.check_pos.objcell_id; sphere_path.curr_cell = check_cell; // :272612 ADVANCE membership SPHEREPATH::cache_global_curr_center(&sphere_path); // re-seed check_* = curr_* for the next sub-step sphere_path.check_pos.objcell_id = sphere_path.curr_pos.objcell_id; sphere_path.check_cell = sphere_path.curr_cell; // :272617 sphere_path.cell_array_valid = 0; } ``` **The anti-flicker guarantee, stated precisely:** - A sub-step that **moves and is accepted** (`OK_TS` && `check_pos != curr_pos`) advances `curr_cell = check_cell` (the cell that the sweep crossed into). - A sub-step that is **blocked/slid/adjusted** (not `OK_TS`) calls `set_check_pos(curr_pos, curr_cell)` — it **rolls the candidate cell BACK to the already-accepted cell**. Membership cannot change on a block. - A sub-step that is **OK but does not move** (`check_pos == curr_pos`) takes the no-op path; membership stays. So push-back jitter — which is exactly a "block" producing a small reversal — never changes `curr_cell`. The cell only changes when the swept path *genuinely crosses* into a new cell and that crossing is accepted. **ACE cross-check [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Transition.cs:984` `ValidateTransition`: ```csharp if (transitionState != TransitionState.OK || SpherePath.CheckPos.Equals(SpherePath.CurPos)) { ... SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); // line 1014 (block path: reset) ... } else SetCurrentCheckPos(); // line 1024 (accept path) ``` `SetCurrentCheckPos()` (`Transition.cs:1084`): ```csharp SpherePath.CurPos = new Position(SpherePath.CheckPos); SpherePath.CurCell = SpherePath.CheckCell; // ADVANCE (line 1087) SpherePath.CacheGlobalCurrCenter(); SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); ``` ACE matches the decomp exactly. ### Step 6 — commit: `SetPositionInternal` reads `curr_cell`, calls `change_cell` only on change `CPhysicsObj::UpdateObjectInternal @ 0x005156b0 (pseudo_c:283611)` **[VERIFIED]**: ```c class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); // :283673 if (eax_10 == 0) { ... no move ... } else { Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); // velocity ... CPhysicsObj::SetPositionInternal(this, eax_10); // :283696 } ``` `CPhysicsObj::SetPositionInternal(CTransition*) @ 0x00515330 (pseudo_c:283399)` **[VERIFIED]** — the membership commit: ```c class CObjCell* curr_cell = arg2->sphere_path.curr_cell; // :283403 READ the swept cell if (curr_cell == 0) { prepare_to_leave_visibility(); store_position(...); GotoLostCell(...); } else { if (this->cell == curr_cell) { // :283414 SAME cell this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id; // just update id // (also propagate id to part_array + children) } else { CPhysicsObj::change_cell(this, curr_cell); // :283456 CHANGE only on differ } CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame); ... } ``` `CPhysicsObj::change_cell @ 0x00513390 (pseudo_c:281192)` **[VERIFIED]**: ```c if (this->cell != 0) CPhysicsObj::leave_cell(this, 1); // remove from old cell's shadow list if (arg2 != 0) { CPhysicsObj::enter_cell(this, arg2); return; } // add to new cell's shadow list this->m_position.objcell_id = 0; this->cell = nullptr; ``` **ACE cross-check [VERIFIED]** — `PhysicsObj.cs:1171` `SetPositionInternal(Transition)`: ```csharp var transitCell = transition.SpherePath.CurCell; // line 1174 READ swept cell ... if (transitCell.Equals(CurCell)) { Position.ObjCellID = curPos.ObjCellID; ... } // line 1192 else change_cell(transitCell); // line 1210 CHANGE only on differ ``` **This is the entire flicker story.** Retail's membership is a *latch*: it is advanced through the sweep and committed at the end, never recomputed from the static endpoint. acdream re-derives it (see D). ## A2. `find_cell_list`: building the candidate array, picking the containing cell, the prune `CObjCell::find_cell_list @ 0x0052b4e0 (pseudo_c:308742)` **[VERIFIED]**. This is the canonical 6-argument overload; the 3-arg form (`@ 0x0052b960 (pseudo_c:309085)`) just forwards from a `SPHEREPATH` (`find_cell_list(&check_pos, num_sphere, global_sphere, cellarray, currcell, path)`). Annotated control flow: ```c void find_cell_list(Position* pos, uint num_sphere, CSphere* sphere, CELLARRAY* arr, CObjCell** out_currCell, SPHEREPATH* path) { arr->num_cells = 0; arr->added_outside = 0; objcell_id = pos->objcell_id; visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // :308754 : CLandCell::GetVisible(objcell_id); // :308756 // (1) SEED the array if (objcell_id >= 0x100) { // interior if (path) path->hits_interior_cell = 1; CELLARRAY::add_cell(arr, objcell_id, visibleCell); // :308766 } else { // outdoor CLandCell::add_all_outside_cells(pos, num_sphere, sphere, arr); // :308769 } if (visibleCell && num_sphere != 0) { // (2) EXPAND through portals: each cell adds its reachable neighbours for (i : arr->num_cells) arr->cells[i].cell->vtable[+0x80](pos, num_sphere, sphere, arr, path); // find_transit_cells :308782 // (3) PICK the single containing cell if (out_currCell) { *out_currCell = nullptr; for (i : arr->num_cells) { cell = arr->cells[i].cell; blockOffset = LandDefs::get_block_offset(pos->objcell_id, cell->id); // :308802 localPoint = sphere->center - blockOffset; if (cell->vtable[+0x84](&localPoint)) { // point_in_cell :308810 *out_currCell = cell; if (cell->id >= 0x100) { // INTERIOR cell wins if (path) path->hits_interior_cell = 1; break; // :308819 short-circuit } } } } } // (4) do_not_load_cells PRUNE if (arr->do_not_load_cells && (pos->objcell_id & 0xFFFF) >= 0x100) { // :308829 for (i : arr->num_cells) { cell_id = arr->cells[i].cell_id; if (cell_id == visibleCell->id) continue; // keep self (+0x28) found = false; for (s : visibleCell->stab_list) // (+0xe0 num, +0xe4 ptr) if (cell_id == s) { found = true; break; } if (!found) CELLARRAY::remove_cell(arr, i); // :308863 DROP non-visible cell } } } ``` **The four mechanisms, explained:** 1. **Seed.** Interior position seeds with its own cell; outdoor seeds with the landcell neighbourhood (`add_all_outside_cells`). 2. **Expand.** `find_transit_cells` (vtable +0x80) adds cells reachable across the foot-sphere from this cell's portals — this is how a sweep that straddles a portal sees both cells. 3. **Pick.** The containing cell is the first whose `point_in_cell` returns true. **An interior cell that contains the point wins and `break`s the loop** (`:308814-308819`) — interior cells take priority over outdoor cells. (Without this, a foot sphere overlapping both the doorway landcell and the vestibule could pick either.) 4. **Prune (`do_not_load_cells`).** When set *and* the position is interior, remove every candidate that is neither the current cell nor in the current cell's `stab_list`. The `stab_list` is the cell's *static* set of "cells I can see." Outdoor landcells are essentially never in an interior cell's stab list, so when the prune is active, **an interior position cannot have the array poisoned by outdoor cells** — which is the membership-stability guarantee. The prune is the "do not stream/consider cells my interior can't see" rule. **`do_not_load_cells` — when is it set?** It is a per-`CELLARRAY` flag (`acclient.h:31577`, `int do_not_load_cells;` in `struct CELLARRAY`). **[VERIFIED]** It is set on the *detection / collision* cell arrays used for object-vs-object queries and for the per-tick transition cell array where you want a stable neighbourhood that doesn't pull in unloaded/invisible cells. ACE exposes it as `CellArray.LoadCells` (inverted sense): when `LoadCells == false` the prune runs. The prune's effect is the stability it buys (item 4 above). **[INFERRED from struct + ACE]** that the per-tick player transition array is built with the prune active for interior movers; the decomp's `find_cell_list` only runs the prune branch when `do_not_load_cells != 0`, and it is unconditionally correct to enable it for an interior position. **ACE cross-check [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335` `find_cell_list` is a near-line-for-line match: seed (`:342-350`), expand (`:354-359`), pick with interior short-circuit (`:365-385`, the `return; // break?` at `:381`), prune (`:387-412`, gated on `!cellArray.LoadCells` and `(position.ObjCellID & 0xFFFF) >= 0x100`, removing cells not in `((EnvCell)visibleCell).VisibleCells`). ## A3. Precisely how retail avoids cell flicker It is the **combination** of A1 + A2, in this order of importance: 1. **Swept-path containment with accept-on-move (A1, dominant).** Membership is advanced only when the sweep crosses into a new cell *and* the crossing sub-step is accepted; a blocked/standing-still sub-step resets the candidate back to the held cell (`validate_transition`, `:272593` reset vs `:272612` advance). The end-of-tick commit reads `curr_cell` and only `change_cell`s on a real difference (`SetPositionInternal`, `:283414` vs `:283456`). A ±8 cm push-back is a *block*, which by construction does not move membership. 2. **Interior-wins short-circuit in `find_cell_list` (A2 item 3).** When a sphere straddles a doorway and both an interior and an outdoor cell contain the point, the interior cell is picked and the loop breaks. This prevents the "should I be in the vestibule or the landcell?" oscillation at the exact threshold. 3. **`do_not_load_cells` prune (A2 item 4).** For an interior mover, the candidate array can never contain an outdoor cell (it's not in the `stab_list`), so the cross/containment machinery operates on a stable interior-only neighbourhood. 4. **`point_in_cell` semantics (BSP, A4).** Containment is a true BSP point-in-solid test in cell-local space, not an AABB — so the boundaries are the actual cell geometry, giving crisp, non-overlapping membership at walls. **What guarantees a blocked/standing-still step does NOT change the cell:** the `set_check_pos(curr_pos, curr_cell)` reset at `validate_transition:272593` (block path) and the `check_pos == curr_pos` no-op path at `:272600-272606` (no move), combined with `SetPositionInternal`'s `if (this->cell == curr_cell)` guard at `:283414`. **[VERIFIED]** ## A4. Transitions: indoor→outdoor, outdoor→indoor, interior→interior; `CCellPortal` vs `CBldPortal` Two distinct portal types exist: - **`CCellPortal`** (`acclient.h:32300`) **[VERIFIED]** — connects two **interior EnvCells** (room↔room). Fields: `other_cell_id`, `other_cell_ptr` (cached neighbour), `portal` (the polygon), `portal_side`, `other_portal_id`, `exact_match`. An `other_cell_id == 0xffffffff` means the portal opens to the **outside** (an exit portal). - **`CBldPortal`** (`acclient.h:32094`) **[VERIFIED]** — connects the **outdoor landblock to a building's interior** (the door from the street). Fields: `portal_side`, `other_cell_id`, `other_portal_id`, `exact_match`, `num_stabs`, `stab_list`, `sidedness`. Held by `CBuildingObj.portals` (`acclient.h:31908`). ### Interior→interior (room↔room) `CEnvCell::find_transit_cells (sphere variant) @ 0x0052c820 (pseudo_c:309968)` **[VERIFIED]**: for each `CCellPortal`, get the neighbour via `CCellPortal::GetOtherCell`, transform the sphere to that cell's local space, and if `CCellStruct::sphere_intersects_cell != OUTSIDE`, `add_cell` the neighbour (`:310054`). The `portal_side` field gates direction. When a portal's `other_cell_id == 0xffffffff` (exterior portal) the function sets `var_44`, and after the loop: ```c if (var_44 != 0) CLandCell::add_all_outside_cells(arg2, arg3, arg4, arg5); // :310119-310120 ``` So an interior cell with an exit portal also pulls in the outdoor landcell neighbourhood — making the *outdoor* landcell a candidate when you're near the door from inside. ### Outdoor→indoor (street→building) The landcell carries a building via `CSortCell` (the `CLandCell : CSortCell` inheritance). `CLandCell::find_transit_cells @ 0x00533800 (pseudo_c:317603)` **[VERIFIED]**: ```c CLandCell::add_all_outside_cells(...); // neighbour landcells CSortCell::find_transit_cells(this, ...); // the building on this landcell ``` `CSortCell::find_transit_cells @ 0x00534060 (pseudo_c:318309)` **[VERIFIED]** → `CBuildingObj::find_building_transit_cells @ 0x006b5230 (pseudo_c:701214)` **[VERIFIED]**: ```c for (i : building->num_portals) { CBldPortal* p = building->portals[i]; CEnvCell* interior = CBldPortal::GetOtherCell(p); if (interior) CEnvCell::check_building_transit(interior, p->other_portal_id, ...); // :701227 } ``` `check_building_transit` adds the interior EnvCell to the array if the sphere has crossed the building portal plane. So when you walk a foot-sphere up to a door from the street, the interior vestibule becomes a candidate, `point_in_cell` picks it (interior wins), and `validate_transition` advances `curr_cell` into the building. ### Indoor→outdoor (exit) As shown in A1 Step 4: when no interior cell contains the sphere center, `check_other_cells` calls `LandDefs::adjust_to_outside` to convert the interior cell id to the surrounding landcell id (`pseudo_c:272783`), then re-resolves. `Position::get_outside_cell_id @ 0x004527b0 (pseudo_c:91552)` **[VERIFIED]** is the helper that computes the outdoor landcell id from an interior position's frame (used by render too). ### How the exit portal / outdoor landcell gets added and chosen - *Added*: via `add_all_outside_cells` (triggered by `var_44` in `find_transit_cells` when an exit portal is present, or directly in the outdoor seed branch). - *Chosen*: by `point_in_cell` in the `find_cell_list` pick loop — but the outdoor cell is only chosen once the sphere center is no longer inside any interior cell (interior wins, A2 item 3). So there is a clean handoff: you remain in the interior until the center leaves the interior solid, then the landcell wins. ## A5. Two mechanisms or one? — the cell ARRAY vs `curr_cell` **They are two distinct things that interact within one transition.** **[VERIFIED]** - **`CELLARRAY cell_array`** (`acclient.h:31574`) — a *set* of cells the collision sweep must test against this tick. Built by `find_cell_list` / `find_transit_cells`. Used by `insert_into_cell` (the primary cell) and `check_other_cells` (every other cell in the array). Its purpose is *collision coverage*: you must collide against walls/floors of every cell your sphere overlaps, not just one. - **`sphere_path.curr_cell` / `check_cell`** — the *single* membership cell. `curr_cell` = accepted; `check_cell` = candidate this sub-step. **How they relate in one transition:** Each sub-step, `check_other_cells` calls `find_cell_list(&cell_array, &out_containing, &sphere_path)`. This: (a) rebuilds the `cell_array` (collision set) for the candidate position, and (b) returns the single containing cell in `out_containing`, which becomes the new `check_cell` (`check_other_cells:272760-272765`). Then `validate_transition` promotes `check_cell → curr_cell` on accept. So the **array drives collision**; the **containing-cell pick drives membership**; both come out of the same `find_cell_list` call but are different outputs. This distinction matters for acdream: acdream already iterates a candidate set for collision (Phase A4 `CheckOtherCells`, `TransitionTypes.cs:2055-2067`), and already retargets `CheckCellId` to the containing cell mid-sweep (`TransitionTypes.cs:2074-2075`). The membership latch is therefore *already tracked* in `sp.CurCellId`. (See D.) --- # B. Underground / dungeons ## B6. How dungeons are represented (dat + runtime), vs building interiors **There is no separate dungeon data type.** Both dungeons and building interiors are made of the same `CEnvCell` graph (`acclient.h:32072`). The difference is purely in the *landblock* they sit on: - A **landblock** (`CLandBlockInfo`, `acclient.h:31893`) **[VERIFIED]** carries `num_cells` + `cell_ids` + `cells (CEnvCell**)` — the interior cells — *and* separately `num_buildings` + `buildings (BuildInfo**)`. - A **dungeon** landblock has terrain heights all zero, ≥1 EnvCell, and **no buildings**. - A **building interior** (cottage/inn) sits on a landblock with **real terrain** and the interior is reached through a `CBldPortal` from the outdoors; the EnvCells frequently have `seen_outside == 1`. **ACE's authoritative definition [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575` `IsDungeon`: ```csharp // a dungeon landblock is determined by: // - all heights being 0 // - having at least 1 EnvCell (0x100+) // - contains no buildings foreach (var height in Height) if (height != 0) { isDungeon = false; return; } isDungeon = Info != null && Info.NumCells > 0 && Info.Buildings != null && Info.Buildings.Count == 0; ``` And `HasDungeon` (`:621`) is the same minus the height check — for landblocks with both an overworld and a basement (e.g. mansions). > **Important caveat:** `IsDungeon`/`HasDungeon` are *ACE server* heuristics for > spawn/teleport logic. The **retail client** does not compute or need them. The > client just renders whatever cell the viewer is in (C12). I cite them only to > characterize the dat layout difference, not as a client code path. **[VERIFIED > they are ACE-only; INFERRED the client has no equivalent — no such function > found in the decomp.]** **Cell id convention** (the runtime discriminator) **[VERIFIED via decomp use]**: - Low 16 bits `0x0001..0x0040` (1–64) → an **outdoor landcell** of a landblock (8×8 grid). `find_cell_list` treats `(id & 0xFFFF) < 0x100` as outdoor (`pseudo_c:308761`, `308753`). - Low 16 bits `>= 0x0100` → an **EnvCell** (interior, building or dungeon). - High 16 bits = the landblock id. So `0xA9B40031` = landblock `0xA9B4`, outdoor cell `0x31`; `0xA9B40170` = landblock `0xA9B4`, EnvCell `0x170`. ## B7. Moving through a dungeon: cell tracking, loading, no sky/terrain - **Cell tracking** is identical to building interiors — the same `transition`/`validate_transition`/`change_cell` machinery, just that *every* cell you cross is an EnvCell (`>= 0x100`) connected by `CCellPortal`s. - **Loading/streaming.** When the player's cell changes, `CEnvCell::grab_visible_cells @ 0x0052e220 (pseudo_c:311878)` **[VERIFIED]** is called (via `CellManager::ChangePosition`, see C13). It adds the current cell + every cell in its `stab_list` to the visible-cell table: ```c CEnvCell::add_visible_cell(this->id); for (i : num_stabs) CEnvCell::add_visible_cell(stab_list[i]); if (this->seen_outside == 0) return; // :311893 — dungeon: STOP, no landscape return LScape::grab_visible_cells(landscape); // building: also load the terrain ``` This is the precise place the engine decides "load the outdoor world too" or not: **`seen_outside == 0` → don't touch the landscape** (dungeon). A building interior with `seen_outside == 1` loads the surrounding terrain. - **No sky/terrain to draw** is decided by `seen_outside` at render time (C10/C12): if the viewer cell and all visible cells have `seen_outside == 0`, no exit portal is ever encountered, so `PView::DrawCells`' `outside_view.view_count` stays 0 and `LScape::draw` is never called (`pseudo_c:432715`). **[VERIFIED]** ## B8. Is there an explicit "underground" flag? **No explicit "underground" flag exists in the retail client.** **[VERIFIED — by absence]**. "Underground" is *emergent*: > You are underground iff your current cell is an `EnvCell` (`id & 0xFFFF >= > 0x100`) whose `seen_outside == 0` and which has no exit-portal path to the > outdoors. The closest thing to a flag is the per-cell **`seen_outside`** boolean (`CObjCell.seen_outside`, `acclient.h:30929`; dat flag `EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/.../EnvCellFlags.cs:7`). **[VERIFIED]**. It is consumed at: - `CEnvCell::grab_visible_cells:311893` (load landscape or not), - `SmartBox::RenderNormalMode:92649` and `CellManager::ChangePosition:94575,94649,94682` (draw landscape / keep landscape loaded). There is **no** `is_underground` field on `Position`, on the landblock, or on the cell. The distinction "dungeon vs building interior vs outdoor" is fully captured by (cell id range) × (`seen_outside`) × (presence of exit portals). --- # C. Rendering inside and outside — the seamless seal ## C9. The single-pass visible-set build (`PView`) The render visibility is a **breadth-first portal traversal** rooted at the viewer's cell, producing an ordered cell-draw list plus per-portal screen clip regions. The data structures (`acclient.h:32346`) **[VERIFIED]**: ```c struct portal_view_type { // one per EnvCell (CEnvCell.portal_view) DArray portal; // per-portal seen/inflag state view_type view; // poly + vertex screen-clip geometry float max_indist; unsigned int view_count; int cell_view_done; int view_timestamp; int update_count; }; struct view_type { unsigned vertex_count_total; DArray poly; DArray vertex; }; ``` ### `PView::ConstructView(CEnvCell* root, ushort) @ 0x005a57b0 (pseudo_c:433750)` **[VERIFIED]** ```c this->outside_view.view_count = 0; PView::master_timestamp += 1; this->cell_todo_num = 0; this->cell_draw_num = 0; PView::InitCell(this, root, arg3); // compute root's active portals + clip PView::InsCellTodoList(this, root, 0); // push root onto BFS todo while (true) { if (cell_todo_num <= 0) return; cell = cell_todo_list[--cell_todo_num].cell; if (cell == 0) return; cell_draw_list[cell_draw_num++] = cell; // ADD to visible draw list cell->portal_view[...]->cell_view_done = 1; if (PView::ClipPortals(this, cell, 0)) // clip this cell's portals to current view PView::AddViewToPortals(this, cell); // enqueue neighbours through visible portals } ``` ### `PView::InitCell @ 0x005a4b70 (pseudo_c:432896)` **[VERIFIED]** For the cell, walks `num_portals`, computes each portal polygon's plane vs the viewer (`Render::FrameCurrent->viewer.viewpoint`), and sets per-portal `inflag`/`seen` based on `portal_side` (the side the viewer is on). Computes `max_indist` (`esi[0xd]`). This decides which of the cell's portals can be seen through from the current viewpoint. ### `PView::AddViewToPortals @ 0x005a52d0 (pseudo_c:433446)` **[VERIFIED]** For each *active* portal of the cell: ```c esi_2 = portal.other_cell_ptr; // neighbour EnvCell if (esi_2 && portal_active) { if (neighbour not yet inited) { InitCell(this, esi_2, portal.other_portal_id); InsCellTodoList(this, esi_2, max_indist); // enqueue neighbour if (portal.flag >= 0) SetOtherSeen(this, cell, portal_idx); // record exit-portal seen } else AddToCell(...); // merge additional view } ``` This is the recursive portal expansion: each visible portal pulls its neighbour cell into the todo list, clipped to the portal's screen region. ### Output - **`cell_draw_list[0..cell_draw_num]`** — the ordered set of visible EnvCells. - Per-cell **`portal_view[...]->view`** — the accumulated screen-space clip region(s) (poly+vertex) through which each cell is visible (so a far cell seen through two doorways is clipped to the intersection). - **`outside_view`** — accumulated clip region for the outdoors seen through exit portals (drives `LScape::draw`, C10). **Cross-check:** WorldBuilder and ACViewer do **NOT** implement this. ACViewer brute-force draws all loaded EnvCells with a `DungeonMode` cull-mode toggle (`references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564`) **[VERIFIED]** — no portal-recursive clip. WorldBuilder uses a flat stencil "inside-out" pass (per project memory `project_indoor_portal_visibility.md`). So **`PView` is the unique authority** for the seamless seal; the references diverge here and the decomp wins. ## C10. Drawing the OUTSIDE through a doorway (no blue hole) Two cooperating mechanisms. ### (a) Exit portals carry the landscape into the indoor traversal In `PView::ClipPortals @ 0x005a5520 (pseudo_c:433572)` **[VERIFIED]**, when a portal's `other_cell_id == 0xffffffff` (an exterior portal): ```c if (*(uint32_t*)esi_3 == 0xffffffff) { // :433662 exit portal if (this->draw_landscape) { // :433664 if (cliplandscape) Render::copy_view(this, &clip_view, ecx_8); // :433674 — landscape clipped to portal else if (draw_landscape) Render::copy_view(this, nullptr, 0); } } ``` So the doorway's screen region (`clip_view`) is registered as a region through which the **landscape** is drawn. The `this->draw_landscape` flag is set when the indoor PView is allowed to show outdoors. ### (b) `DrawCells` draws the landscape first when any portal opened outside `PView::DrawCells @ 0x005a4840 (pseudo_c:432709)` **[VERIFIED]**: ```c if (this->outside_view.view_count > 0) { // :432715 some portal saw outside Render::useSunlightSet(1); Render::PortalList = this; LScape::draw(this->lscape); // :432719 DRAW THE OUTDOOR WORLD (terrain+sky+buildings) D3DPolyRender::FlushAlphaList(0); ... // CONDITIONAL clear — only clears Z where portals were drawn: if (forceClear || D3DPolyRender::portalsDrawnCount != 0) RenderDevice->Clear(4, 0x820fc0, 1.0); // :432731-432732 (4 == Z-only) // then draw each visible EnvCell's drawing_bsp + portal polys: for (cell : cell_draw_list) { if (cell->structure->drawing_bsp) { ... setup_view per portal_view ... for (portal : cell->portals) if (portal.other_cell_id == 0xffffffff) D3DPolyRender::DrawPortalPolyInternal(portal.portal, 0); // :432785-432786 stencil the opening } } for (cell : cell_draw_list) RenderDevice->DrawEnvCell(cell); // :432853 draw interior geometry } ``` **Why there is no blue clear-color hole:** - The landscape (terrain + sky + exterior buildings) is drawn *first*, **inside the doorway clip region**, when `outside_view.view_count > 0`. - The `Clear` is `Clear(4, …)` — flag `4` is the **Z-buffer**, not the full color buffer; and it is *conditional* on portals having been drawn. There is no unconditional `Clear(color)` painting the frame blue. - The exit-portal polygons are drawn as stencil masks (`DrawPortalPolyInternal`) so the outdoors only shows through the actual door opening. So from inside a cottage, looking at the door, you see the real terrain/sky/ rain through the opening — clipped to the door — and the walls around it. The "blue hole" in acdream is precisely the *absence* of step (b): acdream's indoor pass clears to clear-color and never injects the landscape into the doorway clip. `PView::DrawInside @ 0x005a5860 (pseudo_c:433793)` **[VERIFIED]** is the top-level indoor entry that ties it together: ```c CEnvCell::curr_view_push(viewer_cell); PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // seed PVS from stab list Render::copy_view(viewer_cell->portal_view[...], nullptr, 4); edx = PView::ConstructView(this, viewer_cell, 0xffff); // build visible set PView::DrawCells(this, edx); // draw (incl. landscape thru doors) PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); ``` `PView::ConstructView(CBldPortal*, CPolygon*, …) @ 0x005a59a0 (pseudo_c:433827)` **[VERIFIED]** is the *building-portal* sibling (used by `DrawPortal`/outdoor pview): it tests viewer-vs-portal-plane sidedness, gets the interior via `CEnvCell::GetVisible(other_cell_id)`, clips, and recurses `ConstructView(interior, other_portal_id)` (`:433879`). This is how the **outdoor** render draws *into* a building when the camera can see the door from the street — the mirror image of (a)/(b). ## C11. Sealing interiors: capped ceilings, no bleed-through, entity/particle clipping - **Ceilings/walls are sealed by construction.** An EnvCell's geometry (`CCellStruct.polygons` + `drawing_bsp`, `acclient.h:32285`) is a *closed* cell — the dat authoring includes the ceiling/floor/walls as polygons. There is no "cap the ceiling" step; the cell mesh is already a sealed box with portal holes only where `CCellPortal`s exist. `DrawCells` draws `cell->structure->drawing_bsp` for each visible cell (`pseudo_c:432745`, `432853`). **[VERIFIED]** - **No outdoor bleed-in.** Because the visible set is *only* the cells reached by the portal BFS (`cell_draw_list`), and the landscape is drawn *only* through exit-portal clip regions, the outdoor world cannot paint over interior walls. If `seen_outside == 0` everywhere reachable, the landscape is never drawn at all. - **Entity/particle clipping to visible cells.** Objects live in a cell's `shadow_object_list` / `object_list` (`CObjCell.object_list`, `acclient.h:30920`). `DrawCells` draws per-cell objects via `DrawObjCellForDummies(cell)` (`pseudo_c:432878`) walking only the `cell_draw_list`. An object in a non-visible cell is simply not iterated. `Render::PortalList` is set per cell so object draws inherit the same clip region (`:432877`). **[VERIFIED]** Particles attached to objects follow the object's cell membership. ## C12. Terrain + sky: drawn or not, as a function of the current cell The decision is made once per frame in `SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635)` **[VERIFIED]**: ```c // edi_2 ≈ "viewer is in an outdoor landcell" (decompiler-garbled, but the branch is binary) if (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ebx_1 = 1; // :92649 outside-relevant else ebx_1 = 0; if (edi_2 == 0) { // VIEWER IS INSIDE AN ENVCELL if (ebx_1 != 0) { // cell seen_outside: update landscape viewpoint too eax_1 = Position::get_outside_cell_id(&this_1->viewer); LScape::update_viewpoint(this_1->lscape, eax_1); } Render::update_viewpoint(&this_1->viewer); RenderDevice->DrawInside(viewer_cell); // :92675 indoor PView path (draws landscape thru doors if seen_outside) } else { // VIEWER IS OUTSIDE (landcell) LScape::update_viewpoint(this_1->lscape, this_1->viewer.objcell_id); Render::update_viewpoint(&this_1->viewer); Render::set_default_view(); Render::useSunlightSet(1); LScape::draw(this_1->lscape); // :92683 normal outdoor render } ``` So: - **Viewer in an outdoor landcell** (`id & 0xFFFF < 0x100`) → `LScape::draw` (full terrain + sky + buildings; buildings draw *their* interiors via building-portal `ConstructView` when the camera sees the door). - **Viewer in an EnvCell** → `DrawInside` (portal PVS). Terrain/sky appear **only** through exit portals, and only if some reachable cell has `seen_outside`. A dungeon (`seen_outside == 0` everywhere) shows no terrain/sky. This is a single binary on the current cell's id range + `seen_outside`. No separate "is it raining indoors?" logic — rain/sky come through the doorway because the *landscape* is drawn through the exit-portal clip. ## C13. Is render's cell the SAME `curr_cell`/graph as physics? **Yes — render reads the same `objcell_id` and traverses the same cell graph.** **[VERIFIED]** `CellManager::ChangePosition @ 0x004559b0 (pseudo_c:94601)` **[VERIFIED]** is the render-side cell tracker, driven by the *position's* `objcell_id` (the same id physics maintains): ```c uint objcell_id = arg2->objcell_id; // the Position's cell id if (objcell_id == 0) { CellManager::Reset(this); return; } ... if (load_pos.objcell_id != objcell_id || curr_cell == 0) { CellManager::PreFetchCells(this, objcell_id, edi); ... CObjCell* eax_2 = CObjCell::Get(arg2->objcell_id); // :94640 RESOLVE from the same id if (eax_2) { if (ebp_2) { eax_2->grab_visible_cells(); curr_cell = eax_2; } else if (eax_2->seen_outside || keep_lscape_loaded) { // :94649 keep landscape if seen_outside LScape::update_loadpoint(lscape, Position::get_outside_cell_id(arg2)); eax_2->grab_visible_cells(); curr_cell = eax_2; } else { LScape::release_all(lscape); eax_2->grab_visible_cells(); curr_cell = eax_2; } } ... Render::player_pos.objcell_id = curr_cell->pos.objcell_id; // :94671 } ``` The render `CellManager.curr_cell` is `CObjCell::Get(position.objcell_id)` — the same cell graph (`CObjCell::GetVisible`/`CEnvCell::visible_cell_table`) the physics uses (`find_cell_list` resolves `GetVisible(objcell_id)` too, `pseudo_c:308754/308756`). The viewer's cell (`SmartBox.viewer_cell`) is the camera's cell; in 1st person it is the player's cell; in 3rd person the camera may be in a neighbour cell, but it is resolved through the *same* graph. `CObjCell::GetVisible @ 0x0052ad40 (pseudo_c:308209)` **[VERIFIED]** is the shared resolver: ```c return (arg1 >= 0x100) ? CEnvCell::GetVisible(arg1) : CLandCell::GetVisible(arg1); ``` `CEnvCell::GetVisible @ 0x0052dc10 (pseudo_c:311378)` is a hashtable lookup in `CEnvCell::visible_cell_table`; `CLandCell::GetVisible @ 0x00532db0 (pseudo_c:316986)` → `LScape::get_landcell`. **[VERIFIED]** **Conclusion (central to acdream's decision):** retail has **one** cell graph and **one** `objcell_id`. Physics maintains `curr_cell`; render reads `position.objcell_id` and resolves through the same `GetVisible`. There is no separate render-side cell discovery from the camera XYZ — the render cell *is* the physics cell (for the player/viewer). The PVS traversal (`PView`) then expands from that single root through the shared `CEnvCell::portals`. --- # D. Synthesis for acdream ## D-pre. What acdream does today (the gap, precisely) Read in source **[VERIFIED]**: 1. **The sweep already tracks the cell correctly.** acdream's `SpherePath` has `CurCellId` + `CheckCellId` (`src/AcDream.Core/Physics/TransitionTypes.cs:335-336`), and its `ValidateTransition` advances/resets them exactly like retail: ```csharp // TransitionTypes.cs:3404-3434 if (transitionState == OK && sp.CheckPos != sp.CurPos) { sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); } else if (transitionState == OK) { sp.SetCheckPos(sp.CurPos, sp.CurCellId); } else if (transitionState != Invalid) { ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); transitionState = OK; } ``` It also retargets `CheckCellId` to the containing cell mid-sweep (`TransitionTypes.cs:2061-2075`, the Phase A4 `FindCellSet` + `SetCheckPos(sp.CheckPos, containingCellId)`). 2. **The engine THROWS AWAY the tracked cell and re-derives statically.** This is the bug. `PhysicsEngine.ResolveWithTransition` builds the result: ```csharp // PhysicsEngine.cs:907-912 (OK path) resolveResult = new ResolveResult( sp.CheckPos, ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), // :909 RE-DERIVE, sp.CheckCellId only a fallback onGround, collisionNormalValid, collisionNormal); // PhysicsEngine.cs:926-931 (partial/blocked path) resolveResult = new ResolveResult( sp.CheckPos, ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), // :928 RE-DERIVE ...); ``` `ResolveCellId @ PhysicsEngine.cs:294` then re-runs `CellTransit.FindCellList` (indoor) or a terrain-grid lookup (outdoor) **from the static endpoint**, and even re-verifies with a sphere-overlap test + a "doorway hold margin" band-aid (`PhysicsEngine.cs:347-363, 402-421`). This is the static re-derive the prompt identified: jitter at the boundary flips the answer. 3. **No `do_not_load_cells` prune.** `CellTransit.BuildCellSetAndPickContaining` (`CellTransit.cs:426`) builds the candidate set and picks the *first* candidate whose `PointInsideCellBsp` is true (`:524-534`) — with **no interior-wins short-circuit** and **no stab-list prune**. HashSet iteration order makes the pick non-deterministic when multiple cells contain the center. 4. **Render maintains a SEPARATE cell system.** `CellVisibility.FindCameraCell` (`src/AcDream.App/Rendering/CellVisibility.cs:389`) independently re-derives the camera cell from the eye position, *with a 3-frame grace-frame hack* (`CellSwitchGraceFrameCount = 3`, `:214`), ported from ACME's flat-stencil `EnvCellManager` — not retail's PView. `GetVisibleCells` (`:521`) is a separate BFS. This is the root of the "render strobes / blue hole / bleed" family. 5. **The unified seam already exists.** W1 shipped `CellGraph` (`src/AcDream.Core/World/Cells/CellGraph.cs`) with `GetVisible(id)` (the retail resolver) and `CurrCell` (the single membership answer), plus faithful `EnvCell` (`StabList`, `Portals`, `SeenOutside` from `EnvCellFlags.SeenOutside`, `ContainmentBsp`, `PointInCell`). `CurrCell` is currently written by `SetCurrAndReturn` (`PhysicsEngine.cs:287`) but from the *re-derived* id, and it has **no render reader yet**. ## D14. The retail-faithful target architecture **Adopt retail's "track membership through the sweep; one cell graph for physics + render; one portal-visibility traversal" model.** Concretely: 1. **Membership is a latch tracked through the sweep, committed at the end.** `ResolveWithTransition` must return `sp.CurCellId` (the value advanced by `ValidateTransition` / retargeted by the mid-sweep containing-cell pick), **not** a re-derive from the final XYZ. The static `ResolveCellId` is *deleted* from the per-tick output path. This mirrors `CPhysicsObj::SetPositionInternal(CTransition*)` reading `sphere_path.curr_cell` (`pseudo_c:283403`) and `change_cell`-on-differ (`:283414/283456`). 2. **`PlayerMovementController.CellId` becomes "set from `sp.CurCellId`, change only on differ."** Add an explicit "did the cell change?" event so the render and streaming can react (the analog of `change_cell` / `CellManager::ChangePosition`). Write `CellGraph.CurrCell = GetVisible(newId)` here — once, authoritatively. 3. **`CellTransit` gets the interior-wins short-circuit + the `do_not_load_cells` prune.** In `BuildCellSetAndPickContaining`: when a candidate is interior and contains the center, pick it and break (`find_cell_list:308814-308819`); and when the seed is interior, prune every candidate not equal to the seed cell and not in the seed cell's `StabList` (`find_cell_list:308829-308867`). This restores membership stability and makes the pick deterministic — and lets us delete the `DoorwayHoldMargin` band-aid (`PhysicsEngine.cs:416-421`) and the sphere-overlap re-verify (`:347-363`), since the latch + prune make them unnecessary. 4. **Render obeys the physics `CurrCell` and runs ONE portal-visibility traversal.** Replace `CellVisibility.FindCameraCell`'s independent re-derive + grace-frames with: *root the PVS at the player's `CellGraph.CurrCell`* (camera/eye still drives projection, but the visibility ROOT is the physics cell — this is exactly the flap fix already discovered, CLAUDE.md U.4c). For the camera in 3rd person, resolve the camera's cell through the *same* `CellGraph.GetVisible` if needed, but prefer the player cell as root (retail's `viewer_cell` is the camera cell, but acdream's chase camera is the source of the stale-cell flap — keep root = player cell, per the shipped U.4c decision). Then port the `PView::ConstructView` BFS to produce one visible cell list + exit-portal clip regions, and one decision (`SmartBox::RenderNormalMode`): outdoor landcell → terrain pipeline; EnvCell → indoor pipeline that draws the landscape through exit portals when `seen_outside`. 5. **Terrain/sky gating keys off the current cell's id range + `seen_outside`, exactly once.** `(CurrCell.Id & 0xFFFF) < 0x100` → draw terrain/sky. `EnvCell && seen_outside` → draw terrain/sky **only through exit-portal clip regions**. `EnvCell && !seen_outside` (dungeon) → never draw terrain/sky. This replaces the abandoned two-pipe (`cameraInsideBuilding`) split. ## D15. Should membership be advanced in the sweep, and should render obey it? **Yes to both. Justified directly from the decomp:** - **Advance membership inside the sweep (port `validate_transition`'s `curr_cell` advance + the prune + drop the static re-derive):** `validate_transition` is the *only* place retail mutates `curr_cell`, and it does so as a function of whether the swept sub-step was accepted (`pseudo_c:272612` advance vs `:272593` reset). `SetPositionInternal` reads exactly that (`pseudo_c:283403`) and commits via `change_cell`-on-differ. There is **no static endpoint re-derivation anywhere** in retail's per-tick path — `find_cell_list` is called *during* the sweep (in `check_other_cells`) and *at the end with `arg5 == nullptr`* (so it does not pick membership). The `do_not_load_cells` prune is part of that same `find_cell_list` (`pseudo_c:308829`). acdream already has the latch infrastructure (`sp.CurCellId` + `ValidateTransition`); the fix is to *use it* at the engine output and add the prune. - **Render obeys the physics `curr_cell` + a single portal traversal:** `CellManager::ChangePosition` resolves render's `curr_cell` from `position.objcell_id` via `CObjCell::Get` — the *same* graph and id physics maintains (`pseudo_c:94640`). `SmartBox::RenderNormalMode` keys the single inside/outside decision off `viewer_cell` (`pseudo_c:92649,92665`). `PView` expands from that one root. acdream's separate `CellVisibility.FindCameraCell` with grace-frames is non-retail and is the documented source of the flap/strobe; rooting the PVS at the shared `CurrCell` (W2/W3) is the retail-faithful replacement. **[VERIFIED across decomp + ACE + acdream code]** The risk the prompt flags ("touching the collision sweep where acdream has a history of bugs") is **largely avoided** by this plan: the sweep itself is *not* changed (its `CurCellId` tracking is already correct and tested). We change (a) the engine *output* (return `sp.CurCellId`), (b) add a prune in `CellTransit` *pick*, and (c) the *render* root. None of these alter `insert_into_cell` / `FindEnvCollisions` / step-up / AdjustOffset — the parts that have historically broken (issue #98 etc.). This is a key safety property. ## D16. Must-port functions, integration order, risks, conformance tests ### Must-port / must-align functions (with decomp addresses) Physics membership (mostly *already present* in acdream — align, don't rewrite): | Retail function | Address (pseudo_c) | acdream status / action | |---|---|---| | `CTransition::validate_transition` | `0x0050aa70` (272547) | Present as `ValidateTransition` (`TransitionTypes.cs:3398`). **Keep.** | | `CObjCell::find_cell_list` | `0x0052b4e0` (308742) | Present as `CellTransit.BuildCellSetAndPickContaining`. **Add interior-wins break + `do_not_load_cells` prune (`:308814-308867`).** | | `CTransition::check_other_cells` | `0x0050ae50` (272717) | Present as `CheckOtherCells` + mid-sweep retarget (`TransitionTypes.cs:2061-2075`). **Keep; ensure exit→`adjust_to_outside` path exists.** | | `CPhysicsObj::SetPositionInternal(CTransition*)` | `0x00515330` (283399) | **Port the read-`CurCell`/commit-on-differ contract into `ResolveWithTransition`** — return `sp.CurCellId`, drop static `ResolveCellId` at `PhysicsEngine.cs:909/928`. | | `CPhysicsObj::change_cell` | `0x00513390` (281192) | acdream uses a spatial registry, not per-cell shadow lists. **Emit a "cell changed" event when `sp.CurCellId` differs**; align registration with retail's per-cell `shadow_object_list` over time (see memory `feedback_retail_per_cell_shadow_list.md`). | | `LandDefs::adjust_to_outside` | `0x005a9bc0` (438719) | Needed for the interior→outdoor exit id (`check_other_cells:272783`). **Verify acdream's `AddAllOutsideCells` / `Position.get_outside_cell_id` equivalent covers this.** | | `CBuildingObj::find_building_transit_cells` | `0x006b5230` (701214) | Present as `CheckBuildingTransit`. **Keep.** | | `CEnvCell::find_transit_cells` (sphere) | `0x0052c820` (309968) | Present as `FindTransitCellsSphere`. **Keep.** | | `CLandCell::add_all_outside_cells` | `0x00533630` (317499) | Present as `AddAllOutsideCells`. **Keep** (see memory `feedback_latent_bug_masked_by_fallback.md` re: coord convention). | Render visibility (port fresh — this is the unified-pipeline work, Phase U/W3): | Retail function | Address (pseudo_c) | acdream action | |---|---|---| | `SmartBox::RenderNormalMode` | `0x00453aa0` (92635) | Port the single inside/outside decision keyed off `CurrCell` id-range + `seen_outside`. | | `CellManager::ChangePosition` | `0x004559b0` (94601) | Port: render resolves cell from `objcell_id` via `CellGraph.GetVisible`; `grab_visible_cells` on change; keep-landscape when `seen_outside`. | | `CEnvCell::grab_visible_cells` | `0x0052e220` (311878) | Port: add self+`stab_list` to visible set; load landscape iff `seen_outside`. | | `PView::ConstructView` (cell) | `0x005a57b0` (433750) | Port the BFS visible-cell-list builder. | | `PView::InitCell` | `0x005a4b70` (432896) | Port per-portal sidedness/clip init. | | `PView::ClipPortals` | `0x005a5520` (433572) | Port; **the `other_cell_id == 0xffffffff` branch (`:433662`) is the landscape-through-doorway hook.** | | `PView::AddViewToPortals` | `0x005a52d0` (433446) | Port neighbour enqueue + `SetOtherSeen`. | | `PView::DrawCells` | `0x005a4840` (432709) | Port: `if outside_view.view_count>0 → LScape::draw` first; conditional Z-clear (NOT color); per-cell `drawing_bsp`; exit-portal stencil. | | `PView::DrawInside` | `0x005a5860` (433793) | Port the top-level indoor entry. | | `PView::ConstructView` (bld portal) | `0x005a59a0` (433827) | Port the outdoor→building recursion (camera sees door from street). | ### Integration order (lowest-risk first) 1. **W2/W3-physics: stop discarding the swept cell.** Make `ResolveWithTransition` return `sp.CurCellId`; route it to `PlayerMovementController.CellId` and `CellGraph.CurrCell`. Delete the static `ResolveCellId` call at `:909/928` (keep `ResolveCellId` only for non-sweep teleport/spawn seeding). *Smallest, highest-leverage change; does not touch collision math.* Expect the boundary flicker to vanish immediately. 2. **CellTransit pick fix.** Add interior-wins short-circuit + the `do_not_load_cells` prune in `BuildCellSetAndPickContaining`. Then delete the `DoorwayHoldMargin` hold and the sphere-overlap re-verify in `ResolveCellId` (now dead for the per-tick path). *Removes band-aids; makes pick deterministic.* 3. **Render reads `CurrCell`.** Re-root `CellVisibility` at `CellGraph.CurrCell` (player cell), remove `FindCameraCell`'s independent re-derive + the 3-frame grace hack. *This alone fixes the threshold strobe.* 4. **Single PVS + landscape-through-door.** Port `PView::ConstructView` / `DrawCells` so the indoor pass draws the landscape through exit-portal clips (kills the blue hole) and the single `RenderNormalMode` decision replaces the two-pipe split. *Largest piece; do last, gated on visual verification.* 5. **(Longer term) per-cell shadow lists.** Migrate collision registration from the landblock-wide spatial registry to retail's per-cell `shadow_object_list` to close the indoor/outdoor seam issues noted in memory. ### Risks - **Velocity/position consumers of the cell.** Anything that today reads the re-derived cell (streaming radius, audio cell, picking) must switch to `CurrCell`. Audit `SetCurrAndReturn` call sites. *Mitigation:* make the change event explicit; one writer. - **Spawn/teleport seeding still needs a static resolve.** Keep `ResolveCellId` for `MoveOrTeleport`-equivalent seeding (retail's `SetPositionInternal(Position*,…)` path at `pseudo_c:283892` also resolves from scratch). Don't delete it wholesale — only remove it from the per-tick *transition output*. - **`do_not_load_cells` prune over-pruning.** If acdream's `StabList` for a cell is incomplete (dat parse gap), the prune could drop a legitimately-overlapping neighbour and cause a wall to be missed. *Mitigation:* the prune only runs for interior seeds and only removes non-stab cells; verify `EnvCell.StabList` population against `datCell.VisibleCells` (already done in `EnvCell.FromDat`). - **PVS perf / portal-graph blowup (issue #95).** Port `master_timestamp` + `cell_view_done` cycle-guards from `PView` to bound the BFS; cap with `max_indist`. *This is exactly what acdream's separate BFS lacks.* - **Render root vs camera.** Retail's `viewer_cell` is the *camera* cell; acdream's chase camera drifts out of the player cell ~79% of frames (CLAUDE.md U.4c), so **root the PVS at the player cell** (the shipped flap fix), keeping the eye for projection only. Do not regress to camera-rooted visibility. ### Conformance tests (prove faithfulness) 1. **Membership latch (no static re-derive).** Replay the captured cottage/ cellar trajectory (`CellarUpTrajectoryReplayTests` + `ACDREAM_CAPTURE_RESOLVE` JSONL) and assert the per-tick output cell equals the *swept* `sp.CurCellId`, never a re-derive. Specifically: a held-still tick at the doorway must keep the prior cell (no `0x0170↔0x0031` flip). Golden source: live capture shows 1 cell-transit (login) vs 20+ pre-fix (CLAUDE.md A6.P3 slice 3). 2. **Block does not change cell.** Unit test: seed `CurCell=A`, run a transition whose only accepted sub-steps stay in A but whose final push-back nudges the center ~8 cm across the A/B boundary; assert `sp.CurCellId == A` and the committed cell == A. (Directly tests `validate_transition` reset path.) 3. **Interior-wins pick.** Unit test: sphere center inside both a doorway landcell and the vestibule EnvCell; assert `BuildCellSetAndPickContaining` returns the EnvCell and short-circuits. 4. **`do_not_load_cells` prune.** Unit test: interior seed cell with a known `StabList`; inject an outdoor candidate not in the stab list; assert it is removed. Mirror ACE's `find_cell_list:387-412`. 5. **Exit-to-outdoor id.** Unit test: from an interior cell with an exit portal, walk the foot sphere out the door; assert `check_other_cells` produces the correct outdoor landcell id via `adjust_to_outside`. 6. **Render PVS == physics cell.** Integration test: assert the PVS root cell id equals `CellGraph.CurrCell.Id` every frame (no independent camera re-derive). 7. **Seamless seal (visual + headless).** Headless: in a cottage with a known exit portal, assert `PView.outside_view.view_count > 0` and that `LScape::draw` equivalent is invoked (terrain drawn) — i.e. no blue clear-color in the doorway region. Visual: the user confirms sky/rain visible through the door, ceiling sealed, no terrain bleed. (Visual verification is the acceptance gate per CLAUDE.md.) 8. **Dungeon: no terrain/sky.** Headless: in a `seen_outside == 0` EnvCell graph (dungeon), assert the landscape is never grabbed (`grab_visible_cells` returns before `LScape::grab_visible_cells`) and `outside_view.view_count == 0`. --- ## Appendix — disagreements & uncertainties - **`seen_outside` field overload in `CEnvCell::UnPack`.** At `pseudo_c:311044` the decompiler shows `this->seen_outside = ` and indexes it with frames — inconsistent with the verbatim header's `int seen_outside`. This is BN's heuristic field-naming colliding with an adjacent allocation (likely the `voyeur_table`/light-frame array). The **authoritative** facts are (a) the verbatim header `int seen_outside` (`acclient.h:30929`), (b) its boolean use in render (`pseudo_c:92649,94575, 311893`), and (c) the dat flag `EnvCellFlags.SeenOutside = 0x1`. I treat `seen_outside` as a per-cell boolean. **[flagged as decomp-naming noise]** - **`edi_2` in `RenderNormalMode`.** The decompiler garbles the "viewer is in an outdoor landcell" predicate (`pseudo_c:92644-92646`). The branch is clearly binary (`edi_2 == 0` → `DrawInside`, else `LScape::draw`), and the `viewer_cell->seen_outside` term is intact. I infer `edi_2 != 0` ⇔ viewer cell is a landcell. **[INFERRED from branch structure + the parallel `CellManager::ChangePosition` `ebx = (objcell_id < 0x100)` test at `pseudo_c:94568`]** - **`do_not_load_cells` set-site for the player transition.** I verified the *flag's effect* (`find_cell_list:308829`) and ACE's `LoadCells` semantics, but did not pin the exact retail call that sets it on the per-tick player array. The prune is unconditionally correct for interior seeds, so acdream can enable it for interior membership resolution regardless. **[INFERRED]** - **References diverge on the seal.** ACViewer (brute-force draw all EnvCells + `DungeonMode` cull toggle) and WorldBuilder (flat stencil inside-out) do **not** implement `PView`'s portal-clipped landscape-through-door. The decomp is the sole authority for C9–C11, and it wins. **[VERIFIED divergence]** --- ### Citation index (primary decomp anchors) ``` CPhysicsObj::transition 0x00512dc0 pseudo_c:280904 SPHEREPATH::init_path 0x0050ce20 pseudo_c:274359 CTransition::find_valid_position 0x0050c310 pseudo_c:273890 CTransition::find_transitional_position 0x0050bdf0 pseudo_c:273613 CTransition::transitional_insert 0x0050b6f0 pseudo_c:273137 CTransition::insert_into_cell 0x00509e70 pseudo_c:271991 CTransition::check_other_cells 0x0050ae50 pseudo_c:272717 CTransition::validate_transition 0x0050aa70 pseudo_c:272547 CObjCell::find_cell_list (6-arg) 0x0052b4e0 pseudo_c:308742 CObjCell::GetVisible 0x0052ad40 pseudo_c:308209 CEnvCell::GetVisible 0x0052dc10 pseudo_c:311378 CLandCell::GetVisible 0x00532db0 pseudo_c:316986 CEnvCell::point_in_cell 0x0052c300 pseudo_c:309677 CLandCell::point_in_cell 0x00532d40 pseudo_c:316941 CEnvCell::find_transit_cells (sphere) 0x0052c820 pseudo_c:309968 CLandCell::find_transit_cells 0x00533800 pseudo_c:317603 CSortCell::find_transit_cells 0x00534060 pseudo_c:318309 CBuildingObj::find_building_transit_cells 0x006b5230 pseudo_c:701214 CLandCell::add_all_outside_cells 0x00533630 pseudo_c:317499 LandDefs::adjust_to_outside 0x005a9bc0 pseudo_c:438719 Position::get_outside_cell_id 0x004527b0 pseudo_c:91552 CPhysicsObj::SetPositionInternal(CTrans)0x00515330 pseudo_c:283399 CPhysicsObj::change_cell 0x00513390 pseudo_c:281192 CPhysicsObj::UpdateObjectInternal 0x005156b0 pseudo_c:283611 CEnvCell::find_visible_child_cell 0x0052dc50 pseudo_c:311397 CEnvCell::grab_visible_cells 0x0052e220 pseudo_c:311878 SmartBox::RenderNormalMode 0x00453aa0 pseudo_c:92635 CellManager::ChangePosition 0x004559b0 pseudo_c:94601 PView::ConstructView (CEnvCell) 0x005a57b0 pseudo_c:433750 PView::ConstructView (CBldPortal) 0x005a59a0 pseudo_c:433827 PView::InitCell 0x005a4b70 pseudo_c:432896 PView::ClipPortals 0x005a5520 pseudo_c:433572 PView::AddViewToPortals 0x005a52d0 pseudo_c:433446 PView::DrawCells 0x005a4840 pseudo_c:432709 PView::DrawInside 0x005a5860 pseudo_c:433793 Structs (acclient.h): SPHEREPATH 32625 | CObjCell 30915 | CEnvCell 32072 | CLandCell 31886 CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 | CELLARRAY 31574 CELLINFO 31925 | CLandBlockInfo 31893 | CBuildingObj 31908 | portal_view_type 32346 References cross-checked: ACE ObjCell.find_cell_list references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335 ACE Transition.ValidateTransition references/ACE/Source/ACE.Server/Physics/Transition.cs:984 ACE PhysicsObj.SetPositionInternal references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171 ACE Landblock.IsDungeon references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575 ACViewer EnvCellFlags references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7 ACViewer Buffer (no PVS) references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564 acdream current code: PhysicsEngine.ResolveCellId src/AcDream.Core/Physics/PhysicsEngine.cs:294 PhysicsEngine static re-derive src/AcDream.Core/Physics/PhysicsEngine.cs:909,928 SpherePath CurCellId/CheckCellId src/AcDream.Core/Physics/TransitionTypes.cs:335-336 ValidateTransition (advance/reset) src/AcDream.Core/Physics/TransitionTypes.cs:3398-3434 CellTransit pick (no prune) src/AcDream.Core/Physics/CellTransit.cs:426-538 CellVisibility.FindCameraCell src/AcDream.App/Rendering/CellVisibility.cs:389 (+ grace :214) CellGraph (unified, W1) src/AcDream.Core/World/Cells/CellGraph.cs EnvCell.FromDat (StabList/SeenOutside) src/AcDream.Core/World/Cells/EnvCell.cs:42 ```