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

1331 lines
70 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
> **Independent decomp study (model: Opus 4.8, variant A). 2026-06-02.**
> Primary oracle: the named retail decomp at `docs/research/named-retail/`
> (`acclient_2013_pseudo_c.txt`, `acclient.h`, `symbols.json`). Cross-checked
> against `references/ACE` (C# physics port), `references/ACViewer` (MonoGame
> viewer), `references/WorldBuilder`. Citations are `function @ 0xADDR
> (pseudo_c:LINE)` for decomp and `repo/path:LINE` for references. Each
> non-trivial claim is tagged **[VERIFIED]** (read in source) or **[INFERRED]**.
---
## TL;DR (headline findings)
1. **Retail never re-derives the cell from the final XYZ.** It *tracks* the
current cell (`sphere_path.curr_cell`) **through the collision sweep**. The
advance happens in `CTransition::validate_transition` on an accepted
sub-step (`curr_cell = check_cell`), and a **blocked/standing-still
sub-step explicitly resets `check_cell` back to `curr_cell`** — so a
push-back can never change which cell you are in. At the end of the tick,
`CPhysicsObj::SetPositionInternal(CTransition*)` reads
`transition.sphere_path.curr_cell` and calls `change_cell` **only when it
differs** from the held cell. This is the structural cure for the flicker.
2. **`find_cell_list` builds a candidate cell array (containment + portal
neighbours), picks the *single* containing cell — interior cells win and
short-circuit — and then applies the `do_not_load_cells` prune** which
removes every candidate that is neither the current cell nor in the current
interior cell's static visibility list (`stab_list`). The prune is what
buys stability for interior membership: it stops far/irrelevant cells from
ever becoming the answer.
3. **Underground/dungeons are NOT a special mode.** There is no "underground"
flag in the retail client physics or render path. A dungeon is simply a
landblock whose terrain heights are all zero, which has ≥1 EnvCell and no
buildings — so you are always inside an `EnvCell` whose `seen_outside == 0`,
and the renderer therefore never draws terrain or sky. "Inside a building"
differs only in that the EnvCell sits on a real terrain landblock and
(often) has `seen_outside == 1`, so the landscape is still drawn through
the door.
4. **Retail renders inside+outside in ONE portal-visibility traversal
(`PView`).** `SmartBox::RenderNormalMode` makes a single decision per frame
from `viewer_cell` (an outdoor landcell → draw the landscape; an EnvCell →
`DrawInside`). `PView::ConstructView` does a breadth-first portal walk that
produces an ordered visible cell list (`cell_draw_list`) plus per-portal
screen clip regions. When a portal's `other_cell_id == 0xffffffff` (an exit
portal to the outdoors) it pulls the **landscape into the same traversal,
clipped to the doorway**, so there is no blue clear-color hole; sky/rain/
terrain show through. The depth clear is conditional, not an unconditional
blue fill.
5. **Render and physics use the SAME cell graph and the SAME `objcell_id`.**
The render-side `CellManager::ChangePosition` resolves its `curr_cell` via
`CObjCell::Get(position.objcell_id)` — the very same id physics maintains.
`seen_outside` is a per-cell dat flag (`EnvCellFlags.SeenOutside = 0x1`).
Both the physics cell array and the render PVS traverse cells via the same
`CEnvCell::portals` / `stab_list` / `GetVisible` machinery (just different
BSP trees inside the same `CCellStruct`: `physics_bsp`/`cell_bsp` vs
`drawing_bsp`).
6. **acdream's bug is localized and the fix surface already exists.**
acdream's `SpherePath` already tracks `CurCellId`/`CheckCellId` and its
`ValidateTransition` already advances/resets them correctly
(`TransitionTypes.cs:3398-3434`). The *only* defect is at the engine
output: `ResolveWithTransition` (`PhysicsEngine.cs:909` and `:928`) throws
away `sp.CurCellId` and **re-derives the cell statically** via
`ResolveCellId(sp.GlobalSphere[0].Origin, …)`. Plus there is no
`do_not_load_cells` prune in `CellTransit`, and the render maintains a
*separate* cell system (`CellVisibility.FindCameraCell` with a 3-frame
grace-frame band-aid). The unified W1 `CellGraph` (`CurrCell` +
`GetVisible`) is the seam to land all three fixes on.
---
# A. Cell membership & transitions (physics)
## A0. The data model: `SPHEREPATH` and `CObjCell`
The single source of truth for "the cell I'm in" during a move is
`SPHEREPATH::curr_cell`. The struct (verbatim retail header) is:
`acclient.h:32625``struct SPHEREPATH` **[VERIFIED]**:
```c
struct SPHEREPATH {
...
CObjCell *begin_cell; // where the sweep starts (== object's current cell)
Position *begin_pos;
Position *end_pos; // requested destination
CObjCell *curr_cell; // ACCEPTED cell — the committed membership answer
Position curr_pos; // ACCEPTED position
...
CObjCell *check_cell; // CANDIDATE cell being tested this sub-step
Position check_pos; // CANDIDATE position
SPHEREPATH::InsertType insert_type; // TRANSITION_INSERT / PLACEMENT_INSERT
...
CObjCell *backup_cell; // saved for restore (step-up/walkable probes)
Position backup_check_pos;
int hits_interior_cell; // set when an interior cell was added/contained
int cell_array_valid;
...
};
```
The cell objects themselves (`acclient.h:30915` `CObjCell`, `:32072`
`CEnvCell`, `:31886` `CLandCell`) **[VERIFIED]**:
```c
struct CObjCell : SerializeUsingPackDBObj, CPartCell {
LandDefs::WaterType water_type;
Position pos; // cell-to-world frame
...
unsigned int num_shadow_objects;
DArray<CShadowObj *> shadow_object_list; // collision objects registered in this cell
unsigned int restriction_obj;
...
unsigned int num_stabs;
unsigned int *stab_list; // STATIC visibility set: cell ids this cell can see
int seen_outside; // boolean: this interior can see the outdoors
...
CLandBlock *myLandBlock_;
};
struct CEnvCell : CObjCell {
...
CCellStruct *structure; // geometry: vertices, polys, 3 BSP trees
unsigned int num_portals;
CCellPortal *portals; // portal graph edges
unsigned int num_static_objects;
...
unsigned int num_view;
DArray<portal_view_type *> portal_view; // RENDER per-portal clip state
};
struct CLandCell : CSortCell { // CSortCell carries `building` (CBuildingObj*)
CPolygon **polygons;
BoundingType in_view;
};
```
Key takeaways for membership:
- **`curr_cell` ≠ "the cell array".** `curr_cell` is *one* cell (membership).
`cell_array` is the multi-cell set the collision sweep queries (see A5).
- **`stab_list`** is a precomputed per-cell visibility list (the "stabs").
It is used by both the physics prune (A2) and the render PVS (C9).
- **`seen_outside`** is a per-cell boolean meaning "this interior cell has an
exterior portal / can see outdoors." (Confirmed as dat flag
`EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7`). **[VERIFIED]**
## A1. The full membership chain (per tick)
The per-tick movement flows:
`UpdateObjectInternal → transition → find_valid_position → find_transitional_position → (loop) transitional_insert + validate_transition → SetPositionInternal → change_cell`.
### Step 1 — seed `curr_cell` from the object's current cell
`CPhysicsObj::transition @ 0x00512dc0 (pseudo_c:280904)` **[VERIFIED]** builds a
fresh `CTransition`, then at `:280939`:
```c
CTransition::init_path(result, this->cell, arg2 /*m_position*/, arg3 /*newPos*/);
```
`init_path → SPHEREPATH::init_path @ 0x0050ce20 (pseudo_c:274359)` **[VERIFIED]**
seeds:
```c
this->begin_cell = arg2; // == CPhysicsObj.cell
...
this->curr_pos.objcell_id = arg3->objcell_id; // begin pos
this->curr_cell = arg2; // curr_cell STARTS as the held cell (:274370)
this->insert_type = TRANSITION_INSERT;
```
So the sweep *begins* anchored to the cell you are already in.
### Step 2 — the sub-step loop advances `check_pos`, then validates
`CTransition::find_transitional_position @ 0x0050bdf0 (pseudo_c:273613)`
**[VERIFIED]** is the driver. It computes a step count (`calc_num_steps`), then
per sub-step (for `TRANSITION_INSERT`, `pseudo_c:273736-273755`):
```c
// advance the CANDIDATE position by the per-step offset
this->sphere_path.check_pos.frame.origin += global_offset; // :273739-273741
SPHEREPATH::cache_global_sphere(&sphere_path, &global_offset);
var_44 = CTransition::validate_transition(this,
CTransition::transitional_insert(this, 3), &var_40); // :273743
...
ebx += 1;
if (ebx >= var_48) goto done;
continue;
```
At the very end (when all sub-steps are consumed, `pseudo_c:273648-273651`):
```c
SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell);
sphere_path.cell_array_valid = 1;
sphere_path.hits_interior_cell = 0;
CObjCell::find_cell_list(&this->cell_array, nullptr, &this->sphere_path); // rebuild final cell array
```
**Critical**: the final `find_cell_list` rebuilds the *collision* `cell_array`
around the final position, but the **membership answer is already in
`curr_cell`** (advanced by `validate_transition` during the loop). The final
`find_cell_list` is passed `arg5 = nullptr` (no `*currCell` out-param), so it
does NOT re-pick membership.
### Step 3 — `transitional_insert`: one sub-step of collision + cell discovery
`CTransition::transitional_insert @ 0x0050b6f0 (pseudo_c:273137)` **[VERIFIED]**
is the inner stepper. Per iteration:
```c
edi = CTransition::insert_into_cell(this, sphere_path.check_cell, eax_2); // collide in check_cell
switch (edi) {
case OK_TS:
edi = CTransition::check_other_cells(this, sphere_path.check_cell); // discover/cross cells
...
}
```
`insert_into_cell @ 0x00509e70 (pseudo_c:271991)` **[VERIFIED]** simply calls
`arg2->vtable->find_collisions(this)` (the cell's own collision query). On
`OK_TS`, `check_other_cells` runs.
### Step 4 — `check_other_cells`: cross into the right cell mid-sweep
`CTransition::check_other_cells @ 0x0050ae50 (pseudo_c:272717)` **[VERIFIED]**:
```c
sphere_path.cell_array_valid = 1;
sphere_path.hits_interior_cell = 0;
CObjCell::find_cell_list(&this->cell_array, &var_4c /*out: containing cell*/, &sphere_path);
for (i : cell_array) { // collide against every other cell
cell = cell_array.cells[i].cell;
if (cell && cell != arg2)
result = cell->vtable[+0x88](this); // find_collisions
if (result is COLLIDED/ADJUSTED/SLID/CP-clear) handle/return;
}
check_cell = var_4c; // RETARGET candidate cell to the containing cell
if (check_cell != 0) {
SPHEREPATH::adjust_check_pos(&sphere_path, check_cell->id); // rebase check_pos id to new cell
return result;
}
// --- no containing cell found: we have left the indoor graph ---
if (sphere_path.step_down) return COLLIDED_TS;
objcell_id = sphere_path.check_pos.objcell_id;
if (objcell_id < 0x100) { /* already outside */ }
else LandDefs::adjust_to_outside(objcell_id, &frame); // CONVERT interior id -> outdoor landcell id
if (objcell_id == 0) return COLLIDED_TS;
SPHEREPATH::adjust_check_pos(&sphere_path, objcell_id);
sphere_path.check_cell = nullptr; // will be resolved next find_cell_list
```
This is the **indoor→outdoor exit**: when the foot sphere no longer lands in
any portal-connected interior cell, `adjust_to_outside`
(`LandDefs::adjust_to_outside @ 0x005a9bc0 (pseudo_c:438719)` **[VERIFIED]**)
maps the interior cell id to the surrounding landblock's outdoor cell id, and
`check_cell` becomes the landcell on the next pass.
### Step 5 — `validate_transition`: ADVANCE on accept, RESET on block (the anti-flicker)
`CTransition::validate_transition @ 0x0050aa70 (pseudo_c:272547)` **[VERIFIED]**.
The decisive control flow:
```c
if (transitionState != OK || check_pos == curr_pos) // blocked OR no movement
{
if (transitionState != OK) {
if (transitionState != INVALID) { // a real block/slide/adjust
... (contact-plane / last-known-plane handling) ...
SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // :272593
// ^^^ RESET candidate back to curr_pos / curr_cell — cell does NOT change
CTransition::build_cell_array(this, nullptr);
transitionState = OK_TS;
}
} else {
// check_pos == curr_pos: accept-no-move => label_50aba9 advances (no-op)
}
}
else // OK and we moved
{
label_50aba9: // :272608
check_cell = sphere_path.check_cell;
sphere_path.curr_pos.objcell_id = sphere_path.check_pos.objcell_id;
sphere_path.curr_cell = check_cell; // :272612 ADVANCE membership
SPHEREPATH::cache_global_curr_center(&sphere_path);
// re-seed check_* = curr_* for the next sub-step
sphere_path.check_pos.objcell_id = sphere_path.curr_pos.objcell_id;
sphere_path.check_cell = sphere_path.curr_cell; // :272617
sphere_path.cell_array_valid = 0;
}
```
**The anti-flicker guarantee, stated precisely:**
- A sub-step that **moves and is accepted** (`OK_TS` && `check_pos != curr_pos`)
advances `curr_cell = check_cell` (the cell that the sweep crossed into).
- A sub-step that is **blocked/slid/adjusted** (not `OK_TS`) calls
`set_check_pos(curr_pos, curr_cell)` — it **rolls the candidate cell BACK to
the already-accepted cell**. Membership cannot change on a block.
- A sub-step that is **OK but does not move** (`check_pos == curr_pos`) takes
the no-op path; membership stays.
So push-back jitter — which is exactly a "block" producing a small reversal —
never changes `curr_cell`. The cell only changes when the swept path
*genuinely crosses* into a new cell and that crossing is accepted.
**ACE cross-check [VERIFIED]**`references/ACE/Source/ACE.Server/Physics/Transition.cs:984`
`ValidateTransition`:
```csharp
if (transitionState != TransitionState.OK || SpherePath.CheckPos.Equals(SpherePath.CurPos)) {
...
SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); // line 1014 (block path: reset)
...
} else
SetCurrentCheckPos(); // line 1024 (accept path)
```
`SetCurrentCheckPos()` (`Transition.cs:1084`):
```csharp
SpherePath.CurPos = new Position(SpherePath.CheckPos);
SpherePath.CurCell = SpherePath.CheckCell; // ADVANCE (line 1087)
SpherePath.CacheGlobalCurrCenter();
SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell);
```
ACE matches the decomp exactly.
### Step 6 — commit: `SetPositionInternal` reads `curr_cell`, calls `change_cell` only on change
`CPhysicsObj::UpdateObjectInternal @ 0x005156b0 (pseudo_c:283611)` **[VERIFIED]**:
```c
class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); // :283673
if (eax_10 == 0) { ... no move ... }
else {
Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); // velocity
...
CPhysicsObj::SetPositionInternal(this, eax_10); // :283696
}
```
`CPhysicsObj::SetPositionInternal(CTransition*) @ 0x00515330 (pseudo_c:283399)`
**[VERIFIED]** — the membership commit:
```c
class CObjCell* curr_cell = arg2->sphere_path.curr_cell; // :283403 READ the swept cell
if (curr_cell == 0) { prepare_to_leave_visibility(); store_position(...); GotoLostCell(...); }
else {
if (this->cell == curr_cell) { // :283414 SAME cell
this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id; // just update id
// (also propagate id to part_array + children)
} else {
CPhysicsObj::change_cell(this, curr_cell); // :283456 CHANGE only on differ
}
CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame);
...
}
```
`CPhysicsObj::change_cell @ 0x00513390 (pseudo_c:281192)` **[VERIFIED]**:
```c
if (this->cell != 0) CPhysicsObj::leave_cell(this, 1); // remove from old cell's shadow list
if (arg2 != 0) { CPhysicsObj::enter_cell(this, arg2); return; } // add to new cell's shadow list
this->m_position.objcell_id = 0; this->cell = nullptr;
```
**ACE cross-check [VERIFIED]**`PhysicsObj.cs:1171` `SetPositionInternal(Transition)`:
```csharp
var transitCell = transition.SpherePath.CurCell; // line 1174 READ swept cell
...
if (transitCell.Equals(CurCell)) { Position.ObjCellID = curPos.ObjCellID; ... } // line 1192
else change_cell(transitCell); // line 1210 CHANGE only on differ
```
**This is the entire flicker story.** Retail's membership is a *latch*: it is
advanced through the sweep and committed at the end, never recomputed from the
static endpoint. acdream re-derives it (see D).
## A2. `find_cell_list`: building the candidate array, picking the containing cell, the prune
`CObjCell::find_cell_list @ 0x0052b4e0 (pseudo_c:308742)` **[VERIFIED]**. This is
the canonical 6-argument overload; the 3-arg form
(`@ 0x0052b960 (pseudo_c:309085)`) just forwards from a `SPHEREPATH`
(`find_cell_list(&check_pos, num_sphere, global_sphere, cellarray, currcell, path)`).
Annotated control flow:
```c
void find_cell_list(Position* pos, uint num_sphere, CSphere* sphere,
CELLARRAY* arr, CObjCell** out_currCell, SPHEREPATH* path)
{
arr->num_cells = 0;
arr->added_outside = 0;
objcell_id = pos->objcell_id;
visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // :308754
: CLandCell::GetVisible(objcell_id); // :308756
// (1) SEED the array
if (objcell_id >= 0x100) { // interior
if (path) path->hits_interior_cell = 1;
CELLARRAY::add_cell(arr, objcell_id, visibleCell); // :308766
} else { // outdoor
CLandCell::add_all_outside_cells(pos, num_sphere, sphere, arr); // :308769
}
if (visibleCell && num_sphere != 0) {
// (2) EXPAND through portals: each cell adds its reachable neighbours
for (i : arr->num_cells)
arr->cells[i].cell->vtable[+0x80](pos, num_sphere, sphere, arr, path); // find_transit_cells :308782
// (3) PICK the single containing cell
if (out_currCell) {
*out_currCell = nullptr;
for (i : arr->num_cells) {
cell = arr->cells[i].cell;
blockOffset = LandDefs::get_block_offset(pos->objcell_id, cell->id); // :308802
localPoint = sphere->center - blockOffset;
if (cell->vtable[+0x84](&localPoint)) { // point_in_cell :308810
*out_currCell = cell;
if (cell->id >= 0x100) { // INTERIOR cell wins
if (path) path->hits_interior_cell = 1;
break; // :308819 short-circuit
}
}
}
}
}
// (4) do_not_load_cells PRUNE
if (arr->do_not_load_cells && (pos->objcell_id & 0xFFFF) >= 0x100) { // :308829
for (i : arr->num_cells) {
cell_id = arr->cells[i].cell_id;
if (cell_id == visibleCell->id) continue; // keep self (+0x28)
found = false;
for (s : visibleCell->stab_list) // (+0xe0 num, +0xe4 ptr)
if (cell_id == s) { found = true; break; }
if (!found) CELLARRAY::remove_cell(arr, i); // :308863 DROP non-visible cell
}
}
}
```
**The four mechanisms, explained:**
1. **Seed.** Interior position seeds with its own cell; outdoor seeds with the
landcell neighbourhood (`add_all_outside_cells`).
2. **Expand.** `find_transit_cells` (vtable +0x80) adds cells reachable across
the foot-sphere from this cell's portals — this is how a sweep that
straddles a portal sees both cells.
3. **Pick.** The containing cell is the first whose `point_in_cell` returns
true. **An interior cell that contains the point wins and `break`s the
loop** (`:308814-308819`) — interior cells take priority over outdoor
cells. (Without this, a foot sphere overlapping both the doorway landcell
and the vestibule could pick either.)
4. **Prune (`do_not_load_cells`).** When set *and* the position is interior,
remove every candidate that is neither the current cell nor in the current
cell's `stab_list`. The `stab_list` is the cell's *static* set of "cells I
can see." Outdoor landcells are essentially never in an interior cell's
stab list, so when the prune is active, **an interior position cannot have
the array poisoned by outdoor cells** — which is the membership-stability
guarantee. The prune is the "do not stream/consider cells my interior
can't see" rule.
**`do_not_load_cells` — when is it set?** It is a per-`CELLARRAY` flag
(`acclient.h:31577`, `int do_not_load_cells;` in `struct CELLARRAY`). **[VERIFIED]**
It is set on the *detection / collision* cell arrays used for object-vs-object
queries and for the per-tick transition cell array where you want a stable
neighbourhood that doesn't pull in unloaded/invisible cells. ACE exposes it as
`CellArray.LoadCells` (inverted sense): when `LoadCells == false` the prune
runs. The prune's effect is the stability it buys (item 4 above). **[INFERRED
from struct + ACE]** that the per-tick player transition array is built with
the prune active for interior movers; the decomp's `find_cell_list` only runs
the prune branch when `do_not_load_cells != 0`, and it is unconditionally
correct to enable it for an interior position.
**ACE cross-check [VERIFIED]**`references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335`
`find_cell_list` is a near-line-for-line match: seed (`:342-350`), expand
(`:354-359`), pick with interior short-circuit (`:365-385`, the
`return; // break?` at `:381`), prune (`:387-412`, gated on `!cellArray.LoadCells`
and `(position.ObjCellID & 0xFFFF) >= 0x100`, removing cells not in
`((EnvCell)visibleCell).VisibleCells`).
## A3. Precisely how retail avoids cell flicker
It is the **combination** of A1 + A2, in this order of importance:
1. **Swept-path containment with accept-on-move (A1, dominant).** Membership is
advanced only when the sweep crosses into a new cell *and* the crossing
sub-step is accepted; a blocked/standing-still sub-step resets the candidate
back to the held cell (`validate_transition`, `:272593` reset vs `:272612`
advance). The end-of-tick commit reads `curr_cell` and only `change_cell`s
on a real difference (`SetPositionInternal`, `:283414` vs `:283456`). A
±8 cm push-back is a *block*, which by construction does not move membership.
2. **Interior-wins short-circuit in `find_cell_list` (A2 item 3).** When a
sphere straddles a doorway and both an interior and an outdoor cell contain
the point, the interior cell is picked and the loop breaks. This prevents
the "should I be in the vestibule or the landcell?" oscillation at the exact
threshold.
3. **`do_not_load_cells` prune (A2 item 4).** For an interior mover, the
candidate array can never contain an outdoor cell (it's not in the
`stab_list`), so the cross/containment machinery operates on a stable
interior-only neighbourhood.
4. **`point_in_cell` semantics (BSP, A4).** Containment is a true BSP
point-in-solid test in cell-local space, not an AABB — so the boundaries are
the actual cell geometry, giving crisp, non-overlapping membership at walls.
**What guarantees a blocked/standing-still step does NOT change the cell:** the
`set_check_pos(curr_pos, curr_cell)` reset at `validate_transition:272593` (block
path) and the `check_pos == curr_pos` no-op path at `:272600-272606` (no move),
combined with `SetPositionInternal`'s `if (this->cell == curr_cell)` guard at
`:283414`. **[VERIFIED]**
## A4. Transitions: indoor→outdoor, outdoor→indoor, interior→interior; `CCellPortal` vs `CBldPortal`
Two distinct portal types exist:
- **`CCellPortal`** (`acclient.h:32300`) **[VERIFIED]** — connects two **interior
EnvCells** (room↔room). Fields: `other_cell_id`, `other_cell_ptr` (cached
neighbour), `portal` (the polygon), `portal_side`, `other_portal_id`,
`exact_match`. An `other_cell_id == 0xffffffff` means the portal opens to the
**outside** (an exit portal).
- **`CBldPortal`** (`acclient.h:32094`) **[VERIFIED]** — connects the **outdoor
landblock to a building's interior** (the door from the street). Fields:
`portal_side`, `other_cell_id`, `other_portal_id`, `exact_match`,
`num_stabs`, `stab_list`, `sidedness`. Held by `CBuildingObj.portals`
(`acclient.h:31908`).
### Interior→interior (room↔room)
`CEnvCell::find_transit_cells (sphere variant) @ 0x0052c820 (pseudo_c:309968)`
**[VERIFIED]**: for each `CCellPortal`, get the neighbour via
`CCellPortal::GetOtherCell`, transform the sphere to that cell's local space,
and if `CCellStruct::sphere_intersects_cell != OUTSIDE`, `add_cell` the
neighbour (`:310054`). The `portal_side` field gates direction. When a portal's
`other_cell_id == 0xffffffff` (exterior portal) the function sets `var_44`,
and after the loop:
```c
if (var_44 != 0) CLandCell::add_all_outside_cells(arg2, arg3, arg4, arg5); // :310119-310120
```
So an interior cell with an exit portal also pulls in the outdoor landcell
neighbourhood — making the *outdoor* landcell a candidate when you're near the
door from inside.
### Outdoor→indoor (street→building)
The landcell carries a building via `CSortCell` (the `CLandCell : CSortCell`
inheritance). `CLandCell::find_transit_cells @ 0x00533800 (pseudo_c:317603)`
**[VERIFIED]**:
```c
CLandCell::add_all_outside_cells(...); // neighbour landcells
CSortCell::find_transit_cells(this, ...); // the building on this landcell
```
`CSortCell::find_transit_cells @ 0x00534060 (pseudo_c:318309)` **[VERIFIED]**
`CBuildingObj::find_building_transit_cells @ 0x006b5230 (pseudo_c:701214)`
**[VERIFIED]**:
```c
for (i : building->num_portals) {
CBldPortal* p = building->portals[i];
CEnvCell* interior = CBldPortal::GetOtherCell(p);
if (interior) CEnvCell::check_building_transit(interior, p->other_portal_id, ...); // :701227
}
```
`check_building_transit` adds the interior EnvCell to the array if the sphere
has crossed the building portal plane. So when you walk a foot-sphere up to a
door from the street, the interior vestibule becomes a candidate, `point_in_cell`
picks it (interior wins), and `validate_transition` advances `curr_cell` into
the building.
### Indoor→outdoor (exit)
As shown in A1 Step 4: when no interior cell contains the sphere center,
`check_other_cells` calls `LandDefs::adjust_to_outside` to convert the interior
cell id to the surrounding landcell id (`pseudo_c:272783`), then re-resolves.
`Position::get_outside_cell_id @ 0x004527b0 (pseudo_c:91552)` **[VERIFIED]** is the
helper that computes the outdoor landcell id from an interior position's frame
(used by render too).
### How the exit portal / outdoor landcell gets added and chosen
- *Added*: via `add_all_outside_cells` (triggered by `var_44` in
`find_transit_cells` when an exit portal is present, or directly in the
outdoor seed branch).
- *Chosen*: by `point_in_cell` in the `find_cell_list` pick loop — but the
outdoor cell is only chosen once the sphere center is no longer inside any
interior cell (interior wins, A2 item 3). So there is a clean handoff: you
remain in the interior until the center leaves the interior solid, then the
landcell wins.
## A5. Two mechanisms or one? — the cell ARRAY vs `curr_cell`
**They are two distinct things that interact within one transition.** **[VERIFIED]**
- **`CELLARRAY cell_array`** (`acclient.h:31574`) — a *set* of cells the
collision sweep must test against this tick. Built by `find_cell_list` /
`find_transit_cells`. Used by `insert_into_cell` (the primary cell) and
`check_other_cells` (every other cell in the array). Its purpose is
*collision coverage*: you must collide against walls/floors of every cell
your sphere overlaps, not just one.
- **`sphere_path.curr_cell` / `check_cell`** — the *single* membership cell.
`curr_cell` = accepted; `check_cell` = candidate this sub-step.
**How they relate in one transition:** Each sub-step, `check_other_cells` calls
`find_cell_list(&cell_array, &out_containing, &sphere_path)`. This:
(a) rebuilds the `cell_array` (collision set) for the candidate position, and
(b) returns the single containing cell in `out_containing`, which becomes the
new `check_cell` (`check_other_cells:272760-272765`). Then `validate_transition`
promotes `check_cell → curr_cell` on accept. So the **array drives collision**;
the **containing-cell pick drives membership**; both come out of the same
`find_cell_list` call but are different outputs.
This distinction matters for acdream: acdream already iterates a candidate set
for collision (Phase A4 `CheckOtherCells`, `TransitionTypes.cs:2055-2067`), and
already retargets `CheckCellId` to the containing cell mid-sweep
(`TransitionTypes.cs:2074-2075`). The membership latch is therefore *already
tracked* in `sp.CurCellId`. (See D.)
---
# B. Underground / dungeons
## B6. How dungeons are represented (dat + runtime), vs building interiors
**There is no separate dungeon data type.** Both dungeons and building
interiors are made of the same `CEnvCell` graph (`acclient.h:32072`). The
difference is purely in the *landblock* they sit on:
- A **landblock** (`CLandBlockInfo`, `acclient.h:31893`) **[VERIFIED]** carries
`num_cells` + `cell_ids` + `cells (CEnvCell**)` — the interior cells — *and*
separately `num_buildings` + `buildings (BuildInfo**)`.
- A **dungeon** landblock has terrain heights all zero, ≥1 EnvCell, and **no
buildings**.
- A **building interior** (cottage/inn) sits on a landblock with **real
terrain** and the interior is reached through a `CBldPortal` from the
outdoors; the EnvCells frequently have `seen_outside == 1`.
**ACE's authoritative definition [VERIFIED]**
`references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575` `IsDungeon`:
```csharp
// a dungeon landblock is determined by:
// - all heights being 0
// - having at least 1 EnvCell (0x100+)
// - contains no buildings
foreach (var height in Height) if (height != 0) { isDungeon = false; return; }
isDungeon = Info != null && Info.NumCells > 0 && Info.Buildings != null && Info.Buildings.Count == 0;
```
And `HasDungeon` (`:621`) is the same minus the height check — for landblocks
with both an overworld and a basement (e.g. mansions).
> **Important caveat:** `IsDungeon`/`HasDungeon` are *ACE server* heuristics for
> spawn/teleport logic. The **retail client** does not compute or need them. The
> client just renders whatever cell the viewer is in (C12). I cite them only to
> characterize the dat layout difference, not as a client code path. **[VERIFIED
> they are ACE-only; INFERRED the client has no equivalent — no such function
> found in the decomp.]**
**Cell id convention** (the runtime discriminator) **[VERIFIED via decomp use]**:
- Low 16 bits `0x0001..0x0040` (164) → an **outdoor landcell** of a landblock
(8×8 grid). `find_cell_list` treats `(id & 0xFFFF) < 0x100` as outdoor
(`pseudo_c:308761`, `308753`).
- Low 16 bits `>= 0x0100` → an **EnvCell** (interior, building or dungeon).
- High 16 bits = the landblock id. So `0xA9B40031` = landblock `0xA9B4`,
outdoor cell `0x31`; `0xA9B40170` = landblock `0xA9B4`, EnvCell `0x170`.
## B7. Moving through a dungeon: cell tracking, loading, no sky/terrain
- **Cell tracking** is identical to building interiors — the same
`transition`/`validate_transition`/`change_cell` machinery, just that *every*
cell you cross is an EnvCell (`>= 0x100`) connected by `CCellPortal`s.
- **Loading/streaming.** When the player's cell changes,
`CEnvCell::grab_visible_cells @ 0x0052e220 (pseudo_c:311878)` **[VERIFIED]** is
called (via `CellManager::ChangePosition`, see C13). It adds the current cell
+ every cell in its `stab_list` to the visible-cell table:
```c
CEnvCell::add_visible_cell(this->id);
for (i : num_stabs) CEnvCell::add_visible_cell(stab_list[i]);
if (this->seen_outside == 0) return; // :311893 — dungeon: STOP, no landscape
return LScape::grab_visible_cells(landscape); // building: also load the terrain
```
This is the precise place the engine decides "load the outdoor world too" or
not: **`seen_outside == 0` → don't touch the landscape** (dungeon). A
building interior with `seen_outside == 1` loads the surrounding terrain.
- **No sky/terrain to draw** is decided by `seen_outside` at render time
(C10/C12): if the viewer cell and all visible cells have `seen_outside == 0`,
no exit portal is ever encountered, so `PView::DrawCells`'
`outside_view.view_count` stays 0 and `LScape::draw` is never called
(`pseudo_c:432715`). **[VERIFIED]**
## B8. Is there an explicit "underground" flag?
**No explicit "underground" flag exists in the retail client.** **[VERIFIED — by
absence]**. "Underground" is *emergent*:
> You are underground iff your current cell is an `EnvCell` (`id & 0xFFFF >=
> 0x100`) whose `seen_outside == 0` and which has no exit-portal path to the
> outdoors.
The closest thing to a flag is the per-cell **`seen_outside`** boolean
(`CObjCell.seen_outside`, `acclient.h:30929`; dat flag
`EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/.../EnvCellFlags.cs:7`).
**[VERIFIED]**. It is consumed at:
- `CEnvCell::grab_visible_cells:311893` (load landscape or not),
- `SmartBox::RenderNormalMode:92649` and `CellManager::ChangePosition:94575,94649,94682`
(draw landscape / keep landscape loaded).
There is **no** `is_underground` field on `Position`, on the landblock, or on
the cell. The distinction "dungeon vs building interior vs outdoor" is fully
captured by (cell id range) × (`seen_outside`) × (presence of exit portals).
---
# C. Rendering inside and outside — the seamless seal
## C9. The single-pass visible-set build (`PView`)
The render visibility is a **breadth-first portal traversal** rooted at the
viewer's cell, producing an ordered cell-draw list plus per-portal screen
clip regions. The data structures (`acclient.h:32346`) **[VERIFIED]**:
```c
struct portal_view_type { // one per EnvCell (CEnvCell.portal_view)
DArray<portal_info> portal; // per-portal seen/inflag state
view_type view; // poly + vertex screen-clip geometry
float max_indist;
unsigned int view_count;
int cell_view_done;
int view_timestamp;
int update_count;
};
struct view_type { unsigned vertex_count_total; DArray<view_poly> poly; DArray<view_vertex> vertex; };
```
### `PView::ConstructView(CEnvCell* root, ushort) @ 0x005a57b0 (pseudo_c:433750)` **[VERIFIED]**
```c
this->outside_view.view_count = 0;
PView::master_timestamp += 1;
this->cell_todo_num = 0; this->cell_draw_num = 0;
PView::InitCell(this, root, arg3); // compute root's active portals + clip
PView::InsCellTodoList(this, root, 0); // push root onto BFS todo
while (true) {
if (cell_todo_num <= 0) return;
cell = cell_todo_list[--cell_todo_num].cell;
if (cell == 0) return;
cell_draw_list[cell_draw_num++] = cell; // ADD to visible draw list
cell->portal_view[...]->cell_view_done = 1;
if (PView::ClipPortals(this, cell, 0)) // clip this cell's portals to current view
PView::AddViewToPortals(this, cell); // enqueue neighbours through visible portals
}
```
### `PView::InitCell @ 0x005a4b70 (pseudo_c:432896)` **[VERIFIED]**
For the cell, walks `num_portals`, computes each portal polygon's plane vs the
viewer (`Render::FrameCurrent->viewer.viewpoint`), and sets per-portal
`inflag`/`seen` based on `portal_side` (the side the viewer is on). Computes
`max_indist` (`esi[0xd]`). This decides which of the cell's portals can be seen
through from the current viewpoint.
### `PView::AddViewToPortals @ 0x005a52d0 (pseudo_c:433446)` **[VERIFIED]**
For each *active* portal of the cell:
```c
esi_2 = portal.other_cell_ptr; // neighbour EnvCell
if (esi_2 && portal_active) {
if (neighbour not yet inited) {
InitCell(this, esi_2, portal.other_portal_id);
InsCellTodoList(this, esi_2, max_indist); // enqueue neighbour
if (portal.flag >= 0) SetOtherSeen(this, cell, portal_idx); // record exit-portal seen
} else AddToCell(...); // merge additional view
}
```
This is the recursive portal expansion: each visible portal pulls its
neighbour cell into the todo list, clipped to the portal's screen region.
### Output
- **`cell_draw_list[0..cell_draw_num]`** — the ordered set of visible EnvCells.
- Per-cell **`portal_view[...]->view`** — the accumulated screen-space clip
region(s) (poly+vertex) through which each cell is visible (so a far cell
seen through two doorways is clipped to the intersection).
- **`outside_view`** — accumulated clip region for the outdoors seen through
exit portals (drives `LScape::draw`, C10).
**Cross-check:** WorldBuilder and ACViewer do **NOT** implement this. ACViewer
brute-force draws all loaded EnvCells with a `DungeonMode` cull-mode toggle
(`references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564`) **[VERIFIED]** —
no portal-recursive clip. WorldBuilder uses a flat stencil "inside-out" pass
(per project memory `project_indoor_portal_visibility.md`). So **`PView` is the
unique authority** for the seamless seal; the references diverge here and the
decomp wins.
## C10. Drawing the OUTSIDE through a doorway (no blue hole)
Two cooperating mechanisms.
### (a) Exit portals carry the landscape into the indoor traversal
In `PView::ClipPortals @ 0x005a5520 (pseudo_c:433572)` **[VERIFIED]**, when a
portal's `other_cell_id == 0xffffffff` (an exterior portal):
```c
if (*(uint32_t*)esi_3 == 0xffffffff) { // :433662 exit portal
if (this->draw_landscape) { // :433664
if (cliplandscape) Render::copy_view(this, &clip_view, ecx_8); // :433674 — landscape clipped to portal
else if (draw_landscape) Render::copy_view(this, nullptr, 0);
}
}
```
So the doorway's screen region (`clip_view`) is registered as a region through
which the **landscape** is drawn. The `this->draw_landscape` flag is set when
the indoor PView is allowed to show outdoors.
### (b) `DrawCells` draws the landscape first when any portal opened outside
`PView::DrawCells @ 0x005a4840 (pseudo_c:432709)` **[VERIFIED]**:
```c
if (this->outside_view.view_count > 0) { // :432715 some portal saw outside
Render::useSunlightSet(1);
Render::PortalList = this;
LScape::draw(this->lscape); // :432719 DRAW THE OUTDOOR WORLD (terrain+sky+buildings)
D3DPolyRender::FlushAlphaList(0);
...
// CONDITIONAL clear — only clears Z where portals were drawn:
if (forceClear || D3DPolyRender::portalsDrawnCount != 0)
RenderDevice->Clear(4, 0x820fc0, 1.0); // :432731-432732 (4 == Z-only)
// then draw each visible EnvCell's drawing_bsp + portal polys:
for (cell : cell_draw_list) {
if (cell->structure->drawing_bsp) {
... setup_view per portal_view ...
for (portal : cell->portals)
if (portal.other_cell_id == 0xffffffff)
D3DPolyRender::DrawPortalPolyInternal(portal.portal, 0); // :432785-432786 stencil the opening
}
}
for (cell : cell_draw_list) RenderDevice->DrawEnvCell(cell); // :432853 draw interior geometry
}
```
**Why there is no blue clear-color hole:**
- The landscape (terrain + sky + exterior buildings) is drawn *first*, **inside
the doorway clip region**, when `outside_view.view_count > 0`.
- The `Clear` is `Clear(4, …)` — flag `4` is the **Z-buffer**, not the full
color buffer; and it is *conditional* on portals having been drawn. There is
no unconditional `Clear(color)` painting the frame blue.
- The exit-portal polygons are drawn as stencil masks (`DrawPortalPolyInternal`)
so the outdoors only shows through the actual door opening.
So from inside a cottage, looking at the door, you see the real terrain/sky/
rain through the opening — clipped to the door — and the walls around it. The
"blue hole" in acdream is precisely the *absence* of step (b): acdream's indoor
pass clears to clear-color and never injects the landscape into the doorway
clip.
`PView::DrawInside @ 0x005a5860 (pseudo_c:433793)` **[VERIFIED]** is the top-level
indoor entry that ties it together:
```c
CEnvCell::curr_view_push(viewer_cell);
PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // seed PVS from stab list
Render::copy_view(viewer_cell->portal_view[...], nullptr, 4);
edx = PView::ConstructView(this, viewer_cell, 0xffff); // build visible set
PView::DrawCells(this, edx); // draw (incl. landscape thru doors)
PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list);
```
`PView::ConstructView(CBldPortal*, CPolygon*, …) @ 0x005a59a0 (pseudo_c:433827)`
**[VERIFIED]** is the *building-portal* sibling (used by `DrawPortal`/outdoor
pview): it tests viewer-vs-portal-plane sidedness, gets the interior via
`CEnvCell::GetVisible(other_cell_id)`, clips, and recurses
`ConstructView(interior, other_portal_id)` (`:433879`). This is how the
**outdoor** render draws *into* a building when the camera can see the door
from the street — the mirror image of (a)/(b).
## C11. Sealing interiors: capped ceilings, no bleed-through, entity/particle clipping
- **Ceilings/walls are sealed by construction.** An EnvCell's geometry
(`CCellStruct.polygons` + `drawing_bsp`, `acclient.h:32285`) is a *closed*
cell — the dat authoring includes the ceiling/floor/walls as polygons. There
is no "cap the ceiling" step; the cell mesh is already a sealed box with
portal holes only where `CCellPortal`s exist. `DrawCells` draws
`cell->structure->drawing_bsp` for each visible cell (`pseudo_c:432745`,
`432853`). **[VERIFIED]**
- **No outdoor bleed-in.** Because the visible set is *only* the cells reached
by the portal BFS (`cell_draw_list`), and the landscape is drawn *only*
through exit-portal clip regions, the outdoor world cannot paint over
interior walls. If `seen_outside == 0` everywhere reachable, the landscape is
never drawn at all.
- **Entity/particle clipping to visible cells.** Objects live in a cell's
`shadow_object_list` / `object_list` (`CObjCell.object_list`,
`acclient.h:30920`). `DrawCells` draws per-cell objects via
`DrawObjCellForDummies(cell)` (`pseudo_c:432878`) walking only the
`cell_draw_list`. An object in a non-visible cell is simply not iterated.
`Render::PortalList` is set per cell so object draws inherit the same clip
region (`:432877`). **[VERIFIED]** Particles attached to objects follow the
object's cell membership.
## C12. Terrain + sky: drawn or not, as a function of the current cell
The decision is made once per frame in
`SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635)` **[VERIFIED]**:
```c
// edi_2 ≈ "viewer is in an outdoor landcell" (decompiler-garbled, but the branch is binary)
if (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ebx_1 = 1; // :92649 outside-relevant
else ebx_1 = 0;
if (edi_2 == 0) { // VIEWER IS INSIDE AN ENVCELL
if (ebx_1 != 0) { // cell seen_outside: update landscape viewpoint too
eax_1 = Position::get_outside_cell_id(&this_1->viewer);
LScape::update_viewpoint(this_1->lscape, eax_1);
}
Render::update_viewpoint(&this_1->viewer);
RenderDevice->DrawInside(viewer_cell); // :92675 indoor PView path (draws landscape thru doors if seen_outside)
} else { // VIEWER IS OUTSIDE (landcell)
LScape::update_viewpoint(this_1->lscape, this_1->viewer.objcell_id);
Render::update_viewpoint(&this_1->viewer);
Render::set_default_view();
Render::useSunlightSet(1);
LScape::draw(this_1->lscape); // :92683 normal outdoor render
}
```
So:
- **Viewer in an outdoor landcell** (`id & 0xFFFF < 0x100`) → `LScape::draw`
(full terrain + sky + buildings; buildings draw *their* interiors via
building-portal `ConstructView` when the camera sees the door).
- **Viewer in an EnvCell** → `DrawInside` (portal PVS). Terrain/sky appear
**only** through exit portals, and only if some reachable cell has
`seen_outside`. A dungeon (`seen_outside == 0` everywhere) shows no
terrain/sky.
This is a single binary on the current cell's id range + `seen_outside`. No
separate "is it raining indoors?" logic — rain/sky come through the doorway
because the *landscape* is drawn through the exit-portal clip.
## C13. Is render's cell the SAME `curr_cell`/graph as physics?
**Yes — render reads the same `objcell_id` and traverses the same cell graph.**
**[VERIFIED]**
`CellManager::ChangePosition @ 0x004559b0 (pseudo_c:94601)` **[VERIFIED]** is the
render-side cell tracker, driven by the *position's* `objcell_id` (the same id
physics maintains):
```c
uint objcell_id = arg2->objcell_id; // the Position's cell id
if (objcell_id == 0) { CellManager::Reset(this); return; }
...
if (load_pos.objcell_id != objcell_id || curr_cell == 0) {
CellManager::PreFetchCells(this, objcell_id, edi);
...
CObjCell* eax_2 = CObjCell::Get(arg2->objcell_id); // :94640 RESOLVE from the same id
if (eax_2) {
if (ebp_2) { eax_2->grab_visible_cells(); curr_cell = eax_2; }
else if (eax_2->seen_outside || keep_lscape_loaded) { // :94649 keep landscape if seen_outside
LScape::update_loadpoint(lscape, Position::get_outside_cell_id(arg2));
eax_2->grab_visible_cells(); curr_cell = eax_2;
} else { LScape::release_all(lscape); eax_2->grab_visible_cells(); curr_cell = eax_2; }
}
...
Render::player_pos.objcell_id = curr_cell->pos.objcell_id; // :94671
}
```
The render `CellManager.curr_cell` is `CObjCell::Get(position.objcell_id)` — the
same cell graph (`CObjCell::GetVisible`/`CEnvCell::visible_cell_table`) the
physics uses (`find_cell_list` resolves `GetVisible(objcell_id)` too,
`pseudo_c:308754/308756`). The viewer's cell (`SmartBox.viewer_cell`) is the
camera's cell; in 1st person it is the player's cell; in 3rd person the camera
may be in a neighbour cell, but it is resolved through the *same* graph.
`CObjCell::GetVisible @ 0x0052ad40 (pseudo_c:308209)` **[VERIFIED]** is the shared
resolver:
```c
return (arg1 >= 0x100) ? CEnvCell::GetVisible(arg1) : CLandCell::GetVisible(arg1);
```
`CEnvCell::GetVisible @ 0x0052dc10 (pseudo_c:311378)` is a hashtable lookup in
`CEnvCell::visible_cell_table`; `CLandCell::GetVisible @ 0x00532db0
(pseudo_c:316986)``LScape::get_landcell`. **[VERIFIED]**
**Conclusion (central to acdream's decision):** retail has **one** cell graph
and **one** `objcell_id`. Physics maintains `curr_cell`; render reads
`position.objcell_id` and resolves through the same `GetVisible`. There is no
separate render-side cell discovery from the camera XYZ — the render cell *is*
the physics cell (for the player/viewer). The PVS traversal (`PView`) then
expands from that single root through the shared `CEnvCell::portals`.
---
# D. Synthesis for acdream
## D-pre. What acdream does today (the gap, precisely)
Read in source **[VERIFIED]**:
1. **The sweep already tracks the cell correctly.** acdream's
`SpherePath` has `CurCellId` + `CheckCellId`
(`src/AcDream.Core/Physics/TransitionTypes.cs:335-336`), and its
`ValidateTransition` advances/resets them exactly like retail:
```csharp
// TransitionTypes.cs:3404-3434
if (transitionState == OK && sp.CheckPos != sp.CurPos) { sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); }
else if (transitionState == OK) { sp.SetCheckPos(sp.CurPos, sp.CurCellId); }
else if (transitionState != Invalid) { ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); transitionState = OK; }
```
It also retargets `CheckCellId` to the containing cell mid-sweep
(`TransitionTypes.cs:2061-2075`, the Phase A4 `FindCellSet` +
`SetCheckPos(sp.CheckPos, containingCellId)`).
2. **The engine THROWS AWAY the tracked cell and re-derives statically.** This
is the bug. `PhysicsEngine.ResolveWithTransition` builds the result:
```csharp
// PhysicsEngine.cs:907-912 (OK path)
resolveResult = new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), // :909 RE-DERIVE, sp.CheckCellId only a fallback
onGround, collisionNormalValid, collisionNormal);
// PhysicsEngine.cs:926-931 (partial/blocked path)
resolveResult = new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), // :928 RE-DERIVE
...);
```
`ResolveCellId @ PhysicsEngine.cs:294` then re-runs `CellTransit.FindCellList`
(indoor) or a terrain-grid lookup (outdoor) **from the static endpoint**,
and even re-verifies with a sphere-overlap test + a "doorway hold margin"
band-aid (`PhysicsEngine.cs:347-363, 402-421`). This is the static
re-derive the prompt identified: jitter at the boundary flips the answer.
3. **No `do_not_load_cells` prune.** `CellTransit.BuildCellSetAndPickContaining`
(`CellTransit.cs:426`) builds the candidate set and picks the *first*
candidate whose `PointInsideCellBsp` is true (`:524-534`) — with **no
interior-wins short-circuit** and **no stab-list prune**. HashSet iteration
order makes the pick non-deterministic when multiple cells contain the
center.
4. **Render maintains a SEPARATE cell system.** `CellVisibility.FindCameraCell`
(`src/AcDream.App/Rendering/CellVisibility.cs:389`) independently re-derives
the camera cell from the eye position, *with a 3-frame grace-frame hack*
(`CellSwitchGraceFrameCount = 3`, `:214`), ported from ACME's flat-stencil
`EnvCellManager` — not retail's PView. `GetVisibleCells` (`:521`) is a
separate BFS. This is the root of the "render strobes / blue hole / bleed"
family.
5. **The unified seam already exists.** W1 shipped `CellGraph`
(`src/AcDream.Core/World/Cells/CellGraph.cs`) with `GetVisible(id)` (the
retail resolver) and `CurrCell` (the single membership answer), plus faithful
`EnvCell` (`StabList`, `Portals`, `SeenOutside` from `EnvCellFlags.SeenOutside`,
`ContainmentBsp`, `PointInCell`). `CurrCell` is currently written by
`SetCurrAndReturn` (`PhysicsEngine.cs:287`) but from the *re-derived* id, and
it has **no render reader yet**.
## D14. The retail-faithful target architecture
**Adopt retail's "track membership through the sweep; one cell graph for
physics + render; one portal-visibility traversal" model.** Concretely:
1. **Membership is a latch tracked through the sweep, committed at the end.**
`ResolveWithTransition` must return `sp.CurCellId` (the value advanced by
`ValidateTransition` / retargeted by the mid-sweep containing-cell pick),
**not** a re-derive from the final XYZ. The static `ResolveCellId` is
*deleted* from the per-tick output path. This mirrors
`CPhysicsObj::SetPositionInternal(CTransition*)` reading
`sphere_path.curr_cell` (`pseudo_c:283403`) and `change_cell`-on-differ
(`:283414/283456`).
2. **`PlayerMovementController.CellId` becomes "set from `sp.CurCellId`,
change only on differ."** Add an explicit "did the cell change?" event so the
render and streaming can react (the analog of `change_cell` /
`CellManager::ChangePosition`). Write `CellGraph.CurrCell = GetVisible(newId)`
here — once, authoritatively.
3. **`CellTransit` gets the interior-wins short-circuit + the
`do_not_load_cells` prune.** In `BuildCellSetAndPickContaining`: when a
candidate is interior and contains the center, pick it and break
(`find_cell_list:308814-308819`); and when the seed is interior, prune every
candidate not equal to the seed cell and not in the seed cell's `StabList`
(`find_cell_list:308829-308867`). This restores membership stability and
makes the pick deterministic — and lets us delete the `DoorwayHoldMargin`
band-aid (`PhysicsEngine.cs:416-421`) and the sphere-overlap re-verify
(`:347-363`), since the latch + prune make them unnecessary.
4. **Render obeys the physics `CurrCell` and runs ONE portal-visibility
traversal.** Replace `CellVisibility.FindCameraCell`'s independent
re-derive + grace-frames with: *root the PVS at the player's `CellGraph.CurrCell`*
(camera/eye still drives projection, but the visibility ROOT is the physics
cell — this is exactly the flap fix already discovered, CLAUDE.md U.4c). For
the camera in 3rd person, resolve the camera's cell through the *same*
`CellGraph.GetVisible` if needed, but prefer the player cell as root (retail's
`viewer_cell` is the camera cell, but acdream's chase camera is the source of
the stale-cell flap — keep root = player cell, per the shipped U.4c
decision). Then port the `PView::ConstructView` BFS to produce one visible
cell list + exit-portal clip regions, and one decision (`SmartBox::RenderNormalMode`):
outdoor landcell → terrain pipeline; EnvCell → indoor pipeline that draws the
landscape through exit portals when `seen_outside`.
5. **Terrain/sky gating keys off the current cell's id range + `seen_outside`,
exactly once.** `(CurrCell.Id & 0xFFFF) < 0x100` → draw terrain/sky.
`EnvCell && seen_outside` → draw terrain/sky **only through exit-portal clip
regions**. `EnvCell && !seen_outside` (dungeon) → never draw terrain/sky.
This replaces the abandoned two-pipe (`cameraInsideBuilding`) split.
## D15. Should membership be advanced in the sweep, and should render obey it?
**Yes to both. Justified directly from the decomp:**
- **Advance membership inside the sweep (port `validate_transition`'s
`curr_cell` advance + the prune + drop the static re-derive):**
`validate_transition` is the *only* place retail mutates `curr_cell`, and it
does so as a function of whether the swept sub-step was accepted
(`pseudo_c:272612` advance vs `:272593` reset). `SetPositionInternal` reads
exactly that (`pseudo_c:283403`) and commits via `change_cell`-on-differ.
There is **no static endpoint re-derivation anywhere** in retail's per-tick
path — `find_cell_list` is called *during* the sweep (in `check_other_cells`)
and *at the end with `arg5 == nullptr`* (so it does not pick membership). The
`do_not_load_cells` prune is part of that same `find_cell_list`
(`pseudo_c:308829`). acdream already has the latch infrastructure
(`sp.CurCellId` + `ValidateTransition`); the fix is to *use it* at the engine
output and add the prune.
- **Render obeys the physics `curr_cell` + a single portal traversal:**
`CellManager::ChangePosition` resolves render's `curr_cell` from
`position.objcell_id` via `CObjCell::Get` — the *same* graph and id physics
maintains (`pseudo_c:94640`). `SmartBox::RenderNormalMode` keys the single
inside/outside decision off `viewer_cell` (`pseudo_c:92649,92665`). `PView`
expands from that one root. acdream's separate `CellVisibility.FindCameraCell`
with grace-frames is non-retail and is the documented source of the
flap/strobe; rooting the PVS at the shared `CurrCell` (W2/W3) is the
retail-faithful replacement. **[VERIFIED across decomp + ACE + acdream code]**
The risk the prompt flags ("touching the collision sweep where acdream has a
history of bugs") is **largely avoided** by this plan: the sweep itself is
*not* changed (its `CurCellId` tracking is already correct and tested). We
change (a) the engine *output* (return `sp.CurCellId`), (b) add a prune in
`CellTransit` *pick*, and (c) the *render* root. None of these alter
`insert_into_cell` / `FindEnvCollisions` / step-up / AdjustOffset — the parts
that have historically broken (issue #98 etc.). This is a key safety property.
## D16. Must-port functions, integration order, risks, conformance tests
### Must-port / must-align functions (with decomp addresses)
Physics membership (mostly *already present* in acdream — align, don't rewrite):
| Retail function | Address (pseudo_c) | acdream status / action |
|---|---|---|
| `CTransition::validate_transition` | `0x0050aa70` (272547) | Present as `ValidateTransition` (`TransitionTypes.cs:3398`). **Keep.** |
| `CObjCell::find_cell_list` | `0x0052b4e0` (308742) | Present as `CellTransit.BuildCellSetAndPickContaining`. **Add interior-wins break + `do_not_load_cells` prune (`:308814-308867`).** |
| `CTransition::check_other_cells` | `0x0050ae50` (272717) | Present as `CheckOtherCells` + mid-sweep retarget (`TransitionTypes.cs:2061-2075`). **Keep; ensure exit→`adjust_to_outside` path exists.** |
| `CPhysicsObj::SetPositionInternal(CTransition*)` | `0x00515330` (283399) | **Port the read-`CurCell`/commit-on-differ contract into `ResolveWithTransition`** — return `sp.CurCellId`, drop static `ResolveCellId` at `PhysicsEngine.cs:909/928`. |
| `CPhysicsObj::change_cell` | `0x00513390` (281192) | acdream uses a spatial registry, not per-cell shadow lists. **Emit a "cell changed" event when `sp.CurCellId` differs**; align registration with retail's per-cell `shadow_object_list` over time (see memory `feedback_retail_per_cell_shadow_list.md`). |
| `LandDefs::adjust_to_outside` | `0x005a9bc0` (438719) | Needed for the interior→outdoor exit id (`check_other_cells:272783`). **Verify acdream's `AddAllOutsideCells` / `Position.get_outside_cell_id` equivalent covers this.** |
| `CBuildingObj::find_building_transit_cells` | `0x006b5230` (701214) | Present as `CheckBuildingTransit`. **Keep.** |
| `CEnvCell::find_transit_cells` (sphere) | `0x0052c820` (309968) | Present as `FindTransitCellsSphere`. **Keep.** |
| `CLandCell::add_all_outside_cells` | `0x00533630` (317499) | Present as `AddAllOutsideCells`. **Keep** (see memory `feedback_latent_bug_masked_by_fallback.md` re: coord convention). |
Render visibility (port fresh — this is the unified-pipeline work, Phase U/W3):
| Retail function | Address (pseudo_c) | acdream action |
|---|---|---|
| `SmartBox::RenderNormalMode` | `0x00453aa0` (92635) | Port the single inside/outside decision keyed off `CurrCell` id-range + `seen_outside`. |
| `CellManager::ChangePosition` | `0x004559b0` (94601) | Port: render resolves cell from `objcell_id` via `CellGraph.GetVisible`; `grab_visible_cells` on change; keep-landscape when `seen_outside`. |
| `CEnvCell::grab_visible_cells` | `0x0052e220` (311878) | Port: add self+`stab_list` to visible set; load landscape iff `seen_outside`. |
| `PView::ConstructView` (cell) | `0x005a57b0` (433750) | Port the BFS visible-cell-list builder. |
| `PView::InitCell` | `0x005a4b70` (432896) | Port per-portal sidedness/clip init. |
| `PView::ClipPortals` | `0x005a5520` (433572) | Port; **the `other_cell_id == 0xffffffff` branch (`:433662`) is the landscape-through-doorway hook.** |
| `PView::AddViewToPortals` | `0x005a52d0` (433446) | Port neighbour enqueue + `SetOtherSeen`. |
| `PView::DrawCells` | `0x005a4840` (432709) | Port: `if outside_view.view_count>0 → LScape::draw` first; conditional Z-clear (NOT color); per-cell `drawing_bsp`; exit-portal stencil. |
| `PView::DrawInside` | `0x005a5860` (433793) | Port the top-level indoor entry. |
| `PView::ConstructView` (bld portal) | `0x005a59a0` (433827) | Port the outdoor→building recursion (camera sees door from street). |
### Integration order (lowest-risk first)
1. **W2/W3-physics: stop discarding the swept cell.** Make
`ResolveWithTransition` return `sp.CurCellId`; route it to
`PlayerMovementController.CellId` and `CellGraph.CurrCell`. Delete the static
`ResolveCellId` call at `:909/928` (keep `ResolveCellId` only for non-sweep
teleport/spawn seeding). *Smallest, highest-leverage change; does not touch
collision math.* Expect the boundary flicker to vanish immediately.
2. **CellTransit pick fix.** Add interior-wins short-circuit + the
`do_not_load_cells` prune in `BuildCellSetAndPickContaining`. Then delete the
`DoorwayHoldMargin` hold and the sphere-overlap re-verify in `ResolveCellId`
(now dead for the per-tick path). *Removes band-aids; makes pick
deterministic.*
3. **Render reads `CurrCell`.** Re-root `CellVisibility` at
`CellGraph.CurrCell` (player cell), remove `FindCameraCell`'s independent
re-derive + the 3-frame grace hack. *This alone fixes the threshold strobe.*
4. **Single PVS + landscape-through-door.** Port `PView::ConstructView` /
`DrawCells` so the indoor pass draws the landscape through exit-portal clips
(kills the blue hole) and the single `RenderNormalMode` decision replaces the
two-pipe split. *Largest piece; do last, gated on visual verification.*
5. **(Longer term) per-cell shadow lists.** Migrate collision registration from
the landblock-wide spatial registry to retail's per-cell `shadow_object_list`
to close the indoor/outdoor seam issues noted in memory.
### Risks
- **Velocity/position consumers of the cell.** Anything that today reads the
re-derived cell (streaming radius, audio cell, picking) must switch to
`CurrCell`. Audit `SetCurrAndReturn` call sites. *Mitigation:* make the change
event explicit; one writer.
- **Spawn/teleport seeding still needs a static resolve.** Keep `ResolveCellId`
for `MoveOrTeleport`-equivalent seeding (retail's `SetPositionInternal(Position*,…)`
path at `pseudo_c:283892` also resolves from scratch). Don't delete it
wholesale — only remove it from the per-tick *transition output*.
- **`do_not_load_cells` prune over-pruning.** If acdream's `StabList` for a cell
is incomplete (dat parse gap), the prune could drop a legitimately-overlapping
neighbour and cause a wall to be missed. *Mitigation:* the prune only runs for
interior seeds and only removes non-stab cells; verify `EnvCell.StabList`
population against `datCell.VisibleCells` (already done in `EnvCell.FromDat`).
- **PVS perf / portal-graph blowup (issue #95).** Port `master_timestamp` +
`cell_view_done` cycle-guards from `PView` to bound the BFS; cap with
`max_indist`. *This is exactly what acdream's separate BFS lacks.*
- **Render root vs camera.** Retail's `viewer_cell` is the *camera* cell;
acdream's chase camera drifts out of the player cell ~79% of frames (CLAUDE.md
U.4c), so **root the PVS at the player cell** (the shipped flap fix), keeping
the eye for projection only. Do not regress to camera-rooted visibility.
### Conformance tests (prove faithfulness)
1. **Membership latch (no static re-derive).** Replay the captured cottage/
cellar trajectory (`CellarUpTrajectoryReplayTests` + `ACDREAM_CAPTURE_RESOLVE`
JSONL) and assert the per-tick output cell equals the *swept* `sp.CurCellId`,
never a re-derive. Specifically: a held-still tick at the doorway must keep
the prior cell (no `0x0170↔0x0031` flip). Golden source: live capture shows
1 cell-transit (login) vs 20+ pre-fix (CLAUDE.md A6.P3 slice 3).
2. **Block does not change cell.** Unit test: seed `CurCell=A`, run a transition
whose only accepted sub-steps stay in A but whose final push-back nudges the
center ~8 cm across the A/B boundary; assert `sp.CurCellId == A` and the
committed cell == A. (Directly tests `validate_transition` reset path.)
3. **Interior-wins pick.** Unit test: sphere center inside both a doorway
landcell and the vestibule EnvCell; assert `BuildCellSetAndPickContaining`
returns the EnvCell and short-circuits.
4. **`do_not_load_cells` prune.** Unit test: interior seed cell with a known
`StabList`; inject an outdoor candidate not in the stab list; assert it is
removed. Mirror ACE's `find_cell_list:387-412`.
5. **Exit-to-outdoor id.** Unit test: from an interior cell with an exit portal,
walk the foot sphere out the door; assert `check_other_cells` produces the
correct outdoor landcell id via `adjust_to_outside`.
6. **Render PVS == physics cell.** Integration test: assert the PVS root cell id
equals `CellGraph.CurrCell.Id` every frame (no independent camera re-derive).
7. **Seamless seal (visual + headless).** Headless: in a cottage with a known
exit portal, assert `PView.outside_view.view_count > 0` and that `LScape::draw`
equivalent is invoked (terrain drawn) — i.e. no blue clear-color in the
doorway region. Visual: the user confirms sky/rain visible through the door,
ceiling sealed, no terrain bleed. (Visual verification is the acceptance gate
per CLAUDE.md.)
8. **Dungeon: no terrain/sky.** Headless: in a `seen_outside == 0` EnvCell graph
(dungeon), assert the landscape is never grabbed (`grab_visible_cells` returns
before `LScape::grab_visible_cells`) and `outside_view.view_count == 0`.
---
## Appendix — disagreements & uncertainties
- **`seen_outside` field overload in `CEnvCell::UnPack`.** At
`pseudo_c:311044` the decompiler shows `this->seen_outside = <operator new[]>`
and indexes it with frames — inconsistent with the verbatim header's
`int seen_outside`. This is BN's heuristic field-naming colliding with an
adjacent allocation (likely the `voyeur_table`/light-frame array). The
**authoritative** facts are (a) the verbatim header `int seen_outside`
(`acclient.h:30929`), (b) its boolean use in render (`pseudo_c:92649,94575,
311893`), and (c) the dat flag `EnvCellFlags.SeenOutside = 0x1`. I treat
`seen_outside` as a per-cell boolean. **[flagged as decomp-naming noise]**
- **`edi_2` in `RenderNormalMode`.** The decompiler garbles the "viewer is in an
outdoor landcell" predicate (`pseudo_c:92644-92646`). The branch is clearly
binary (`edi_2 == 0` → `DrawInside`, else `LScape::draw`), and the
`viewer_cell->seen_outside` term is intact. I infer `edi_2 != 0` ⇔ viewer cell
is a landcell. **[INFERRED from branch structure + the parallel
`CellManager::ChangePosition` `ebx = (objcell_id < 0x100)` test at
`pseudo_c:94568`]**
- **`do_not_load_cells` set-site for the player transition.** I verified the
*flag's effect* (`find_cell_list:308829`) and ACE's `LoadCells` semantics, but
did not pin the exact retail call that sets it on the per-tick player array.
The prune is unconditionally correct for interior seeds, so acdream can enable
it for interior membership resolution regardless. **[INFERRED]**
- **References diverge on the seal.** ACViewer (brute-force draw all EnvCells +
`DungeonMode` cull toggle) and WorldBuilder (flat stencil inside-out) do **not**
implement `PView`'s portal-clipped landscape-through-door. The decomp is the
sole authority for C9C11, and it wins. **[VERIFIED divergence]**
---
### Citation index (primary decomp anchors)
```
CPhysicsObj::transition 0x00512dc0 pseudo_c:280904
SPHEREPATH::init_path 0x0050ce20 pseudo_c:274359
CTransition::find_valid_position 0x0050c310 pseudo_c:273890
CTransition::find_transitional_position 0x0050bdf0 pseudo_c:273613
CTransition::transitional_insert 0x0050b6f0 pseudo_c:273137
CTransition::insert_into_cell 0x00509e70 pseudo_c:271991
CTransition::check_other_cells 0x0050ae50 pseudo_c:272717
CTransition::validate_transition 0x0050aa70 pseudo_c:272547
CObjCell::find_cell_list (6-arg) 0x0052b4e0 pseudo_c:308742
CObjCell::GetVisible 0x0052ad40 pseudo_c:308209
CEnvCell::GetVisible 0x0052dc10 pseudo_c:311378
CLandCell::GetVisible 0x00532db0 pseudo_c:316986
CEnvCell::point_in_cell 0x0052c300 pseudo_c:309677
CLandCell::point_in_cell 0x00532d40 pseudo_c:316941
CEnvCell::find_transit_cells (sphere) 0x0052c820 pseudo_c:309968
CLandCell::find_transit_cells 0x00533800 pseudo_c:317603
CSortCell::find_transit_cells 0x00534060 pseudo_c:318309
CBuildingObj::find_building_transit_cells 0x006b5230 pseudo_c:701214
CLandCell::add_all_outside_cells 0x00533630 pseudo_c:317499
LandDefs::adjust_to_outside 0x005a9bc0 pseudo_c:438719
Position::get_outside_cell_id 0x004527b0 pseudo_c:91552
CPhysicsObj::SetPositionInternal(CTrans)0x00515330 pseudo_c:283399
CPhysicsObj::change_cell 0x00513390 pseudo_c:281192
CPhysicsObj::UpdateObjectInternal 0x005156b0 pseudo_c:283611
CEnvCell::find_visible_child_cell 0x0052dc50 pseudo_c:311397
CEnvCell::grab_visible_cells 0x0052e220 pseudo_c:311878
SmartBox::RenderNormalMode 0x00453aa0 pseudo_c:92635
CellManager::ChangePosition 0x004559b0 pseudo_c:94601
PView::ConstructView (CEnvCell) 0x005a57b0 pseudo_c:433750
PView::ConstructView (CBldPortal) 0x005a59a0 pseudo_c:433827
PView::InitCell 0x005a4b70 pseudo_c:432896
PView::ClipPortals 0x005a5520 pseudo_c:433572
PView::AddViewToPortals 0x005a52d0 pseudo_c:433446
PView::DrawCells 0x005a4840 pseudo_c:432709
PView::DrawInside 0x005a5860 pseudo_c:433793
Structs (acclient.h):
SPHEREPATH 32625 | CObjCell 30915 | CEnvCell 32072 | CLandCell 31886
CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 | CELLARRAY 31574
CELLINFO 31925 | CLandBlockInfo 31893 | CBuildingObj 31908 | portal_view_type 32346
References cross-checked:
ACE ObjCell.find_cell_list references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335
ACE Transition.ValidateTransition references/ACE/Source/ACE.Server/Physics/Transition.cs:984
ACE PhysicsObj.SetPositionInternal references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171
ACE Landblock.IsDungeon references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575
ACViewer EnvCellFlags references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7
ACViewer Buffer (no PVS) references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564
acdream current code:
PhysicsEngine.ResolveCellId src/AcDream.Core/Physics/PhysicsEngine.cs:294
PhysicsEngine static re-derive src/AcDream.Core/Physics/PhysicsEngine.cs:909,928
SpherePath CurCellId/CheckCellId src/AcDream.Core/Physics/TransitionTypes.cs:335-336
ValidateTransition (advance/reset) src/AcDream.Core/Physics/TransitionTypes.cs:3398-3434
CellTransit pick (no prune) src/AcDream.Core/Physics/CellTransit.cs:426-538
CellVisibility.FindCameraCell src/AcDream.App/Rendering/CellVisibility.cs:389 (+ grace :214)
CellGraph (unified, W1) src/AcDream.Core/World/Cells/CellGraph.cs
EnvCell.FromDat (StabList/SeenOutside) src/AcDream.Core/World/Cells/EnvCell.cs:42
```