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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:58:51 +02:00

59 KiB
Raw Blame History

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):

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):

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:

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 doneno 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):

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:

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):

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):

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

  • 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 CBldPortals (A4.2). Some of their CCellPortals 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: transitionvalidate_transition advances curr_cell across CCellPortals (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):

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:

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 InitCells the neighbor and InsCellTodoLists 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 CEnvCells, 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):

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:

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, GetClips 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 readResolveWithTransition 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)

  • SPHEREPATHacclient.h:32625-32671 (curr_cell:32641, check_cell:32647, hits_interior_cell:32655, cell_array_valid:32666, num_sphere:32627, global_curr_center:32635).
  • CELLARRAYacclient.h:31574-31580 (added_outside, do_not_load_cells, num_cells, cells).
  • CELLINFOacclient.h:31925-31929 (cell_id, cell).
  • CObjCellacclient.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 : CObjCellacclient.h:31880-31883 (building).
  • CLandCell : CSortCellacclient.h:31886-31890 (polygons, in_view).
  • CEnvCell : CObjCellacclient.h:32072-32091 (structure, num_portals/portals, num_static_objects/ static_objects, light_array, num_view/portal_view:32089-32090).
  • CCellStructacclient.h:32275-32290 (portals(CPolygon**), polygons, drawing_bsp/physics_bsp/cell_bsp).
  • CCellPortalacclient.h:32300-32308 (other_cell_id, other_cell_ptr, portal, portal_side, other_portal_id, exact_match).
  • CBldPortalacclient.h:32094-32103 (portal_side, other_cell_id, other_portal_id, exact_match, num_stabs/stab_list, sidedness).
  • CBuildingObj : CPhysicsObjacclient.h:31908-31916 (num_portals/portals, num_leaves/leaf_cells, shadow_list).
  • CLandBlockInfoacclient.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.