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