acdream/docs/research/2026-06-02-retail-cell-render-study-sonnet46.md
Erik 840c1b6442 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>
2026-06-02 13:58:51 +02:00

811 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.