acdream/docs/research/2026-06-02-retail-cell-render-study-opus48-b.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

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