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>
894 lines
59 KiB
Markdown
894 lines
59 KiB
Markdown
# 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.
|