docs(render): Phase W (rev) — 4-model research + transition-membership/PView design

Four independent decomp studies (Opus 4.8 x2, Sonnet 4.6, external Codex)
converge: retail carries the cell through the collision sweep (validate_transition
advances curr_cell only on an accepted move, reverts on a block) and commits it in
SetPositionInternal — it never re-derives membership from a static resting position.
acdream already ports the sweep machinery (sp.CurCellId/CheckCellId, ValidateTransition,
CheckOtherCells) but ResolveWithTransition discards the swept cell and re-derives
statically via ResolveCellId (PhysicsEngine.cs:909/928) — the root of the
0170<->0031 doorway/cellar ping-pong. The do_not_load_cells prune is secondary
(static/cross-cell lists), not the anti-flicker; W2b was doubly misplaced and is reverted.

Render: one PView::ConstructView portal traversal over the same cell graph, rooted at
the physics current cell; seen_outside (not a dungeon flag) gates landscape; the outside
draws through exit portals clipped to the doorway (no blue-hole, no stencil split).
Dungeons/interiors share the machinery; "underground" is emergent.

Design doc lays out the staged, evidence-first rewrite (Stage 0 diagnostic ->
Stage 1 transition-owned membership [visual gate] -> Stage 2 CELLARRAY/prune parity ->
Stages 3-5 render root + PView seal + entity clip). Adds the shared research prompt and
all four study reports as the grounding record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 13:58:51 +02:00
parent 2acd8f9e1d
commit 840c1b6442
6 changed files with 3608 additions and 0 deletions

View file

@ -0,0 +1,811 @@
# 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:3262532671) 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 308751308769):
```
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 308771308786):
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 308788308827):
```
*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 308829308867):
```
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:3157431580):**
```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:528529).
`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:309983310030).
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:5233252335):
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 14 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:9464994661). 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:432785432791:
```
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:9266592684):
```
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:9276192892) 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:311044311057). 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:11711211` confirms `SetPositionInternal` reads `SpherePath.CurCell`
directly (not via position re-derive). ACE `ObjCell.cs:335413` confirms the `find_cell_list`
logic including `do_not_load_cells` (`LoadCells` flag in ACE, CellArray.cs:8).
ACE `Transition.cs:9841091` (`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.