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

70 KiB
Raw Blame History

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:32625struct SPHEREPATH [VERIFIED]:

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

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:

CTransition::init_path(result, this->cell, arg2 /*m_position*/, arg3 /*newPos*/);

init_path → SPHEREPATH::init_path @ 0x0050ce20 (pseudo_c:274359) [VERIFIED] seeds:

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

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

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:

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

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:

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:

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

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

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:

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

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

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:

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 breaks 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_cells 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:

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

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

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:

// 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 CCellPortals.
  • 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:
    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]:

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]

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:

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

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

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:

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 CCellPortals 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]:

// 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 EnvCellDrawInside (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):

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:

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:

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

    // 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 == 0DrawInside, 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