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

42 KiB
Raw Blame History

Retail AC: Cell Transitions, Underground/Dungeons, and Seamless Indoor/Outdoor Rendering

Independent Research Study — claude-sonnet-4-6 — 2026-06-02

Scope: This is an independent study of the retail AC client (Sept 2013 EoR build, Sept PDB) as primary oracle, cross-checked against ACE and the acdream codebase. Every non-trivial claim is cited with function @ 0xADDR (pseudo_c:LINE) or repo/file:LINE. Inferences are flagged explicitly.


A. Cell Membership & Transitions (Physics)

A1. How retail stores and updates curr_cell

The authoritative field is SPHEREPATH::curr_cell (acclient.h:32641).

SPHEREPATH (acclient.h:3262532671) contains two parallel position/cell pairs:

Field Purpose
curr_cell / curr_pos The accepted position after the last completed move
check_cell / check_pos The candidate position being tested in the current sweep step
begin_cell / begin_pos The position at the start of this full transition

SPHEREPATH::curr_cell is not a cache of some ID-to-pointer lookup — it is the live pointer to the CObjCell the physics sphere currently inhabits. The CPhysicsObj also maintains this->cell (set by enter_cell/leave_cell), which mirrors curr_cell after SetPositionInternal completes the write-back.

Where curr_cell is updated during a transition:

  1. CTransition::validate_transition @ 0x0050aa70 (pseudo_c:272547) — the single canonical place where a step is accepted.

    // On OK path (move accepted, check_pos != curr_pos):
    this->sphere_path.curr_pos   = check_pos   // line 272609-272611
    this->sphere_path.curr_cell  = check_cell  // line 272612
    // then resets check_pos/check_cell to the new curr:
    this->sphere_path.check_pos  = curr_pos    // line 272615-272616
    this->sphere_path.check_cell = curr_cell   // line 272617
    // cell_array_valid cleared so next build_cell_array refreshes
    this->sphere_path.cell_array_valid = 0     // line 272618
    
    // On non-OK path (collision, slide):
    // check_pos is reset BACK to curr_pos (line 272593)
    // curr_cell is NOT changed — sphere stays in curr_cell
    

    Key invariant: curr_cell only advances when validate_transition accepts the move (returns OK_TS with check_pos != curr_pos). A bounce/slide resets check_pos to curr_pos without touching curr_cell.

  2. CTransition::check_other_cells @ 0x0050ae50 (pseudo_c:272717) — updates sphere_path.check_cell after find_cell_list picks a containing cell:

    CObjCell::find_cell_list(&cell_array, &var_4c, &sphere_path);  // line 272725
    // var_4c = the containing cell picked by point_in_cell scan
    sphere_path.check_cell = var_4c;  // line 272761
    if (var_4c != 0):
        adjust_check_pos(var_4c->id)  // line 272765
    

    This is what replaces check_cell with the correct new cell across a portal boundary during a sweep step.

  3. CPhysicsObj::SetPositionInternal @ 0x00515330 (pseudo_c:283399) — the write-back from transition result to CPhysicsObj::cell:

    CObjCell* curr_cell = arg2->sphere_path.curr_cell;  // line 283403
    if (curr_cell == 0): prepare_to_leave_visibility(); store_position();
    else:
        if (this->cell == curr_cell):
            // same cell — just update the cell id on position/parts
            this->m_position.objcell_id = curr_pos.objcell_id;
        else:
            change_cell(this, curr_cell);   // line 283456
    

    CPhysicsObj::cell is only updated here, from sphere_path.curr_cell delivered by the completed transition. There is NO intermediate re-derive from world position.

  4. CPhysicsObj::change_cell @ 0x00513390 (pseudo_c:281192) — the actual leave/enter:

    if (this->cell != 0): leave_cell(this, 1);
    if (arg2 != 0): enter_cell(this, arg2);
    else: this->cell = nullptr;
    

    enter_cell @ 0x00510ed0 (pseudo_c:278928) calls CObjCell::add_object(arg2, this) to register the physics object in the new cell's object list.

Summary chain:

transitional_insert (sweep loop)
  → insert_into_cell (per-cell BSP test)
  → check_other_cells (find_cell_list → pick containing cell → update check_cell)
  → validate_transition (accept: curr_cell = check_cell; reject: reset check to curr)
  → [loop until done]
→ SetPositionInternal (write curr_cell → change_cell → CPhysicsObj::cell)

A2. find_cell_list: candidate building and containing-cell selection

CObjCell::find_cell_list @ 0x0052b4e0 (pseudo_c:308742) — this is the workhorse. Signature (from the 6-argument overload):

void CObjCell::find_cell_list(
    Position const* pos,
    uint32_t numSphere,
    CSphere const* spheres,
    CELLARRAY* cellArray,
    CObjCell** containingCell,   // OUT: the single containing cell
    SPHEREPATH* path)

Step-by-step:

  1. Seed the starting cell (lines 308751308769):

    if pos.objcell_id >= 0x100: visible = CEnvCell::GetVisible(id)   // indoor
    else: visible = CLandCell::GetVisible(id)                          // outdoor
    if id >= 0x100:
        path->hits_interior_cell = 1
        CELLARRAY::add_cell(cellArray, id, visible)
    else:
        CLandCell::add_all_outside_cells(pos, numSphere, spheres, cellArray)
    
  2. Expand via find_transit_cells (lines 308771308786): Each cell already in the array is asked to add any neighboring cells that the sphere overlaps through portals:

    for each cell in cellArray:
        cell->vtable->find_transit_cells(pos, numSphere, spheres, cellArray, path)
    

    For CEnvCell::find_transit_cells @ 0x0052c820 (pseudo_c:309968): for each portal, checks if any sphere overlaps the portal plane (with radius epsilon). If so, either adds portal.other_cell (if resolved) or adds the portal's landcell side. For CLandCell::find_transit_cells @ 0x00533800 (pseudo_c:317603): calls add_all_outside_cells then CSortCell::find_transit_cells.

  3. Pick the containing cell (*containingCell, lines 308788308827):

    *containingCell = nullptr
    for each cell in cellArray:
        blockOffset = LandDefs::get_block_offset(pos.objcell_id, cell.id)
        localPoint = sphere[0].center - blockOffset
        if cell->vtable->point_in_cell(localPoint):
            *containingCell = cell
            if cell.id >= 0x100:
                path->hits_interior_cell = 1
                break  // interior cell wins immediately, no further scan
    

    Interior cell wins over landcell — once a CEnvCell is found to contain the point, the loop breaks. Outdoor landcells are not preferred over interior cells.

  4. do_not_load_cells prune (lines 308829308867):

    if cellArray.do_not_load_cells AND pos.objcell_id >= 0x100:
        remove any cell from cellArray whose id is not:
            - the id of the 'visible' (GetVisible) cell, or
            - one of visible's stab_list members (its PVS set)
    

    This prune runs after the containing-cell scan. It removes cells that are reachable via portal overlaps but not actually visible from the current interior starting cell. It only fires when do_not_load_cells = 1 AND the position is already indoors (id >= 0x100). The prune uses visible's stab_list array — the precomputed PVS of cells visible from that EnvCell — as the whitelist.

What stability the prune buys: When the sphere straddles a portal into a neighboring CEnvCell, the transition normally adds that neighbor to the cellArray and may pick it as containing cell. With do_not_load_cells, neighbors NOT in the current cell's PVS are stripped — the sphere can only "move into" a cell that is visible from where it currently is. This prevents teleporting through walls into cells whose portals don't connect to the current room.

When do_not_load_cells is set:

  • CPhysicsObj::SetPositionInternal @ 0x00515bd0 (pseudo_c:283930) sets it: cell_array.do_not_load_cells = 1 (line 283930) before calling find_cell_list. This is the placement/teleport path, not the per-frame movement path.
  • Also set at 0x00519895 (pseudo_c:287856) in the detection manager.
  • The normal movement transition (find_transitional_position) path does NOT set do_not_load_cells — the sweep is allowed to discover any adjacent cell.

CELLARRAY struct (acclient.h:3157431580):

struct CELLARRAY {
    int  added_outside;
    int  do_not_load_cells;
    uint num_cells;
    DArray<CELLINFO> cells;   // each entry: {uint cell_id; CObjCell* cell;}
};

A3. How retail avoids cell flicker at boundaries

Retail's anti-flicker guarantee is directionalcurr_cell only advances when a step is accepted by validate_transition. A blocked step resets check_pos/check_cell back to curr_pos/curr_cell (pseudo_c:272593). The player standing still fires collision tests that may push-back but never accept, so curr_cell never changes.

The key mechanisms:

  1. Sweep-path tracking, not static lookup. The sweep starts at curr_cell and advances check_cell through portals only when the sphere physically crosses through a portal (detected by find_transit_cells). A position jitter of ±8cm across a door frame does NOT cause curr_cell to flip — it would only flip if validate_transition accepted a step that moved check_pos past the portal.

  2. point_in_cell semantics. The containing-cell selection in find_cell_list uses CObjCell::point_in_cell (vtable+0x84) which for CEnvCell::point_in_cell @ 0x0052c300 (pseudo_c:309677) does a BSP containment test:

    eax_1 = this->vtable->point_in_cell(arg2)  // delegates to CCellStruct::point_in_cell
    

    CCellStruct::point_in_cell @ 0x005338f0 (pseudo_c:317657) tests the BSP. The BSP boundary is not a simple plane — it is the full convex cell volume defined by the portal geometry. So "across the threshold" in world space may still be "inside the cell" in BSP space for some margin.

  3. Interior cell wins the point_in_cell scan. In the containing-cell scan (pseudo_c:308814-308820), once an interior cell (id >= 0x100) passes point_in_cell, the loop breaks immediately. This ensures that even if the outdoor landcell ALSO passes point_in_cell (because the player is standing ON a building's footprint), the indoor cell takes priority.

  4. check_cell is set from the collision result, not re-derived from world position. After check_other_cells updates check_cell to the find_cell_list result, the next iteration of the sweep uses THAT check_cell as the source for insert_into_cell. There is no re-derive from the player's world XYZ.

  5. Standing still: When targetPos == currentPos, the transition has zero steps. find_transitional_position returns immediately (ACE Transition.cs:528529). curr_cell is never touched. (Inference: based on the ACE port — retail likely has the same short-circuit.)

acdream's divergence: PhysicsEngine.cs:909/928 calls ResolveCellId(sp.GlobalSphere[0].Origin, ...) which re-derives the cell from the final world XYZ via CellTransit.FindCellList. This is a static BFS from position. If the BSP push-back moves the sphere center 8cm outside the indoor cell's BSP volume, FindCellList can return the outdoor landcell — which is exactly the ping-pong symptom. Retail never does this; it reads sphere_path.curr_cell directly.

A4. Indoor→outdoor and outdoor→indoor transitions; CCellPortal vs CBldPortal

CCellPortal (acclient.h:32300) — connects two CEnvCells within the same building or dungeon:

struct CCellPortal {
    uint       other_cell_id;    // id of the neighbor EnvCell
    CEnvCell*  other_cell_ptr;   // live pointer (null when not loaded)
    CPolygon*  portal;           // the polygon defining the opening
    int        portal_side;      // which side of the polygon is "inside" this cell
    int        other_portal_id;  // index of corresponding portal in other_cell
    int        exact_match;      // match only when crossing from exact side
};

CBldPortal (acclient.h:32094) — connects a building's CBuildingObj to the outdoor landscape or to an EnvCell within the building:

struct CBldPortal {
    int        portal_side;       // 0 or 1
    uint       other_cell_id;     // 0 for outdoor exits
    int        other_portal_id;   // -1 for outdoor exits
    int        exact_match;
    uint       num_stabs;
    uint*      stab_list;         // PVS of interior cells visible through this portal
    float      sidedness;
};

Outdoor → indoor entry: CEnvCell::check_building_transit @ 0x0052c5d0 (pseudo_c:309827) is called from find_transit_cells. For each portal in the building (CBldPortal), it tests whether any physics sphere overlaps the cell BSP (CCellStruct::sphere_intersects_cell). If so, it calls CELLARRAY::add_cell(arg5, this->m_DID.id, this) — adding the interior EnvCell to the collision candidate array. After the next find_cell_list run, point_in_cell on the indoor cell will win (interior > outdoor), and check_cell becomes the indoor cell. On the next validate_transition accept, curr_cell advances to the indoor cell.

Indoor → outdoor exit: CEnvCell::find_transit_cells @ 0x0052c820 (pseudo_c:309968): when a portal's other_cell_id == 0xFFFFFFFF (exit portal — sentinel value), the code tests whether any sphere plane is within epsilon of the exit portal plane (pseudo_c:309983310030). If so, it calls CLandCell::add_all_outside_cells (line 310120) — adding the surrounding landcells. The point_in_cell scan then picks the outdoor landcell.

Between interior cells: Handled purely via CCellPortal in find_transit_cells. If the sphere overlaps the CCellPortal.portal polygon (plane-distance test with radius epsilon), other_cell_ptr (if non-null) is added to the array.

A5. The two arrays: CELLARRAY for collision vs curr_cell for membership

These are NOT the same. CTransition owns both:

// CTransition (acclient.h:5233252335):
SPHEREPATH sphere_path;  // contains curr_cell, check_cell
CELLARRAY  cell_array;   // the candidate array for collision testing

cell_array is rebuilt each step by build_cell_arrayfind_cell_list. It contains ALL cells whose BSPs the sphere might overlap (typically 14 cells at a boundary). Every cell in the array gets an insert_into_cell collision test.

curr_cell in sphere_path is the single cell that CONTAINS the sphere center — the membership answer. It's updated only via validate_transition's accept path.

The relationship: each step's find_cell_list scan computes both (1) the candidate array for collision and (2) the containing cell (*containingCell arg). They share the same find_cell_list call but serve different purposes.


B. Underground / Dungeons

B6. Dungeon representation — EnvCell graph vs surface buildings

Surface buildings (cottages, inns):

  • Built on top of a landblock with terrain.
  • CLandBlockInfo.buildings array references CBuildingObj instances.
  • Each building's CBuildingObj has portals (array of CBldPortal*) connecting it to the indoor CEnvCell graph and to the outdoor landscape.
  • The CLandCell for that terrain square is always present; the CEnvCell cells of the building sit "on top" of it spatially but are physically separate cells.
  • CEnvCell::seen_outside (acclient.h:30929, type int) is non-zero for cells that have at least one exit portal reaching the landscape (inn doorway cells, cellar stairs top-cell, etc.).

Dungeons:

  • Represented as a pure CEnvCell graph, loaded from the DAT as the CLandBlockInfo for the dungeon block. There is no terrain CLandCell in the dungeon landblock.
  • All CEnvCells in a dungeon have seen_outside = 0 (pseudo_c:311370 shows it's zeroed on init; the unpack path at pseudo_c:311044 / 311057 can set it from DAT data, but dungeon cells universally have no outdoor reachability).
  • The dungeon is entered via a portal link from a surface landblock CEnvCell (the dungeon entrance cell) to the dungeon's first cell, mediated by the same find_transit_cells mechanism.

At runtime there is no explicit "I am in a dungeon" boolean. The engine tests curr_cell->seen_outside to decide whether terrain/sky applies.

B7. Player movement through a dungeon

Cell tracking in a dungeon is identical to inside a surface building — the same CEnvCell sweep via CCellPortal. The dungeon cell graph is self-contained. The absence of CLandCell means find_cell_list never adds outdoor cells:

  • CObjCell::find_cell_list seeds from CEnvCell::GetVisible(id) (id >= 0x100 branch), adds only the starting EnvCell.
  • CEnvCell::find_transit_cells tests each portal. Exit portals with other_cell_id == 0xFFFFFFFF exist in surface buildings to reach the landscape; dungeon cells don't have such portals — their portals all connect to other dungeon CEnvCells.

Streaming/loading: CEnvCell::grab_visible_cells @ 0x0052e220 (pseudo_c:311880):

add_visible_cell(this->id)
for each stab in stab_list: add_visible_cell(stab)    // the PVS
if seen_outside != 0: LScape::grab_visible_cells()    // only for outdoor-reachable cells

Dungeon cells (seen_outside==0) never trigger LScape::grab_visible_cells. The landscape is completely excluded from their rendering context.

B8. The "underground" flag

There is no explicit is_dungeon or is_underground boolean on Position, landblock, or cell. The engine uses CObjCell::seen_outside (acclient.h:30929) as the semantic gate:

  • Non-zero: this cell can see the outside world (sky, terrain visible through portals/exits)
  • Zero: fully enclosed (pure dungeon cell, or interior cell with no exterior windows)

The decision tree (from SmartBox::RenderNormalMode @ 0x00453aa0, pseudo_c:92635):

edi_2 = (viewer_cell == null) ? 1 : 0    // no viewer cell = outdoor default
if edi_2 == 0:
    ebx_1 = viewer_cell->seen_outside != 0  // 1=can see outside, 0=sealed
if edi_2 == 0:
    if ebx_1: LScape::update_viewpoint + DrawInside(viewer_cell)  // indoor+terrain
    else: DrawInside(viewer_cell) only (no terrain)
else:
    LScape::update_viewpoint + LScape::draw  // pure outdoor

This is the master terrain/sky gate. The seen_outside field on viewer_cell (the render-side cell for the viewer position) determines whether terrain renders.

CellManager::ChangePosition @ 0x004559b0 (pseudo_c:94601) also reads seen_outside on the new curr_cell to decide whether to LScape::release_all vs grab_visible_cells (pseudo_c:9464994661). For a cell with seen_outside != 0, it calls LScape::update_loadpoint to keep terrain around the outdoor cell ID loaded.


C. Rendering Inside and Outside

C9. The PView visibility traversal — one pass, one BFS

Retail's render visibility is built by PView::ConstructView @ 0x005a57b0 (pseudo_c:433750). This is a breadth-first portal traversal, not a recursive frustum split. The same PView instance is reused for all cells seen from the current viewer_cell.

PView::ConstructView(this, rootCell, incomingPortalIdx):
    reset outside_view, master_timestamp, cell_todo_num, cell_draw_num
    InitCell(this, rootCell, 0xFFFF)   // set up per-portal visibility masks
    InsCellTodoList(this, rootCell, 0f) // push root into the work queue

    while cell_todo_num > 0:
        cell = pop from cell_todo_list
        add cell to cell_draw_list
        cell.portal_view[last].cell_view_done = 1
        if ClipPortals(this, cell, 0):   // compute per-portal clip regions
            AddViewToPortals(this, cell) // propagate visibility through each portal

InitCell @ 0x005a4b70 (pseudo_c:432896) initializes the per-portal visibility state for the root cell: it checks each portal's plane against the current viewer position (Render::FrameCurrent) to determine which portals face the viewer. Portals that face away are marked as non-visible. The result is stored in portal_view_type structs on the CEnvCell.

ClipPortals clips each visible portal to the accumulated clip region (the frustum intersection of all portals traversed so far). If a portal's clip region is non-empty, AddViewToPortals is called, which calls ConstructView recursively for the neighboring cell through that portal (pseudo_c:433879):

if arg5 != 1:
    PView::ConstructView(this, eax_4, arg2->other_portal_id)

The result is cell_draw_list — an ordered list of CEnvCell* to draw, built in visibility order from the viewer.

Output: PView::cell_draw_list (acclient.h:45939) — a DArray<CEnvCell*>, the ordered draw list. PView::outside_view accumulates outdoor portal entries for landscape/sky draws.

C10. Outside seen through a doorway — exit portals and seen_outside

When the traversal reaches a cell with an exit portal (CBldPortal with other_cell_id == 0), PView::ConstructView(this, bldPortal, polygon, ...) is called (the CBldPortal overload at 0x005a59a0, pseudo_c:433827):

PView::ConstructView(this, bldPortal, polygon, arg4, arg5):
    test viewer position against portal plane (sidedness check):
    if portal_side correct:
        GetClip(this, sidedness, polygon, &clip_view, ...)
        if clip_view non-empty:
            eax_4 = CEnvCell::GetVisible(bldPortal->other_cell_id)  // indoor neighbor
            if arg5 != 2:
                D3DPolyRender::DrawPortalPolyInternal(polygon, ...)  // draw the portal hole
            PView::ConstructView(this, eax_4, bldPortal->other_portal_id)  // recurse indoor

For outdoor exit portals specifically, the exit goes to the outdoor_pview via PView::DrawPortal @ 0x005a5ab0 (pseudo_c:433895). The outdoor_pview is the landscape PView; DrawPortal calls ConstructView(outdoor_pview, bldPortal, portal, ...). Inside PView::DrawCells @ 0x005a4840 (pseudo_c:432709), when outside_view.view_count > 0:

Render::useSunlightSet(1)
Render::PortalList = this
LScape::draw(this->lscape)    // terrain + sky through this portal's clip region
D3DPolyRender::FlushAlphaList(0f)
// ... then draw the indoor env cells

The landscape draw uses Render::PortalList (set to this PView) to clip the terrain to the portal opening's region — only the terrain visible through that portal hole is drawn. This is how the outside world appears through a doorway without a blue hole.

No blue-hole guarantee: The portal hole is always either (a) filled by the D3DPolyRender::DrawPortalPolyInternal call (which masks the stencil/z-buffer for the opening) or (b) reveals a valid outdoor PView result. The DrawPortalPolyInternal call draws the portal polygon as a "window" into the outdoor view. The outdoor view is computed by outdoor_pview using its ConstructView with the portal's clip shape as the accumulated frustum.

C11. How retail seals interiors — ceiling caps, entity clip, portal masking

Retail's interior seal comes from the PView system itself:

  1. Portal-hole masking: Each portal polygon is drawn as a "window" into the neighboring view. The rendering device clips draws to the visible portal region. Cells not in cell_draw_list are never drawn.

  2. cell_draw_list is the only draw gate. PView::DrawCells iterates only cell_draw_list.data[0..cell_draw_num]. An entity or particle in a cell not in cell_draw_list is not drawn. There is no separate frustum cull — portal visibility IS the culling.

  3. Ceiling/floor capping: The CCellStruct BSP for each CEnvCell includes all surfaces (floor, ceiling, walls). When DrawEnvCell renders the cell geometry, all surfaces including the ceiling are drawn. The only surfaces NOT drawn are portals (they get DrawPortalPolyInternal treatment instead of normal polygon rendering) — pseudo_c:432785432791:

    if portals[j].other_cell_ptr == 0xffffffff:
        D3DPolyRender::DrawPortalPolyInternal(portal, 0)
    

    So: ceiling is always part of the cell mesh and always drawn. There is no "open top" unless the cell geometry has an actual hole.

  4. Entity draw: DrawCells also calls DrawObjCellForDummies (pseudo_c:432878) for each cell in cell_draw_list, which draws entities registered in that cell's object_list. Entities in non-visible cells are never drawn.

C12. Terrain and sky: the seen_outside gate

The draw path (from SmartBox::RenderNormalMode @ 0x00453aa0, pseudo_c:92635):

if viewer_cell == null OR viewer_cell->seen_outside != 0:
    // Player is outdoor OR in a cell that can see outside
    if viewer_cell != null (indoor-reachable-outside case):
        LScape::update_viewpoint(viewer.objcell_id)
    Render::update_viewpoint(&viewer)
    RenderDevice::DrawInside(viewer_cell)  // fires PView traversal + portal terrain draws
else (viewer_cell != null AND seen_outside == 0):
    // Player in sealed cell (dungeon or sealed room)
    LScape::update_viewpoint(viewer.objcell_id)
    Render::update_viewpoint(&viewer)
    RenderDevice::DrawInside(viewer_cell)  // PView traversal only, no landscape

Wait — actually reading more carefully (pseudo_c:9266592684):

if edi_2 == 0 (viewer_cell != null):
    if ebx_1 (seen_outside): LScape::update_viewpoint + DrawInside
    else: DrawInside only (no landscape update)
else (viewer_cell == null):
    LScape::update_viewpoint + LScape::draw  // pure outdoor: direct landscape draw

Terrain through portals is NOT drawn via LScape::draw when indoor — it's drawn inside PView::DrawCells via LScape::draw(this->lscape) only when outside_view.view_count > 0 (there are outdoor portal entries). This means:

  • Sealed dungeon cell (seen_outside=0, no exit portals): PView::DrawCells never sets outside_view.view_count > 0, so LScape::draw is never called.
  • Building cell with exit portals: exit portal traversal adds an entry to outside_view; LScape::draw fires inside DrawCells.

Sky follows the same path: LScape::draw includes sky. When there are no outdoor portal views, sky is not drawn.

C13. Render viewer_cell vs physics curr_cell — the same graph, different holders

Retail uses the SAME physical CObjCell* for both physics and render, but held by different owners.

Physics: CPhysicsObj::cell (the player's CPhysicsObj) = current cell pointer, updated by change_cell after SetPositionInternal.

Render: SmartBox::viewer_cell (acclient.h:35194, type CObjCell*) = the render entry point for DrawInside. This is updated by SmartBox::update_viewer @ 0x00453ce0 (pseudo_c:92761).

SmartBox::update_viewer (pseudo_c:9276192892) is called every frame. It:

  1. Reads this->player->cell (the physics cell).
  2. If player->cell == null: calls reenter_visibility, sets viewer_cell = null.
  3. Otherwise, runs a camera position transition: CTransition::find_valid_position (line 92868) sweeps the viewer_sphere from player_pos to viewer_sought_position (the 3rd-person camera target position).
  4. Sets viewer_cell = transition.sphere_path.curr_cell (line 92871) — directly from the camera transition result, NOT from re-deriving by position.
  5. If the camera transition fails: falls back to CPhysicsObj::AdjustPosition on the viewer sphere, setting viewer_cell from that result.
  6. If both fail: viewer_cell = nullptr (outdoor).

Key insight: SmartBox::viewer_cell is a separate tracked pointer for the camera/render, but it is resolved via its own CTransition sweep (the camera spring-arm sweep). It does NOT re-derive from the camera's world XYZ via find_cell_list directly — it uses the transition result's curr_cell. When the camera is fully outdoor (no indoor cell found), viewer_cell is null, and the outdoor path fires.

The two cell pointers:

  • CPhysicsObj::cell — physics membership, updated per-frame by SetPositionInternal
  • SmartBox::viewer_cell — render root, updated per-frame by update_viewer via its own camera transition sweep

They share the same CObjCell graph (the runtime-loaded cells) but are tracked independently. Critically, viewer_cell does NOT necessarily equal player->cell — in 3rd-person mode the camera can be in a different cell than the player body.

The render traversal uses viewer_cell (not player->cell) as the PView root. This is the CAMERA's cell, not the physics body's cell.


D14. Retail-faithful target architecture

Given what the decomp shows, the correct architecture is:

Physics side (cell membership):

  • sphere_path.curr_cell tracks the player's cell THROUGH the sweep.
  • curr_cell advances only when validate_transition accepts a step.
  • Blocked/standing-still steps never change curr_cell.
  • After the sweep, SetPositionInternal writes sphere_path.curr_cellCPhysicsObj::cell.
  • No static re-derive from world position after the sweep.

Camera/render side (viewer cell):

  • SmartBox::viewer_cell is resolved via its OWN CTransition::find_valid_position sweep on the camera eye sphere each frame.
  • This camera sweep returns its own sphere_path.curr_cell which becomes viewer_cell.
  • viewer_cell is what the PView traversal roots from — not player->cell.
  • The PView traversal (ConstructView → BFS → cell_draw_list) computes the entire visible set from viewer_cell in ONE pass.
  • Terrain/sky/landscape draw is gated on viewer_cell->seen_outside.

The two subsystems share the same cell graph but track their own positions independently. They can be in different cells (player body vs camera).

D15. Should acdream port validate_transition's curr_cell advance + drop static re-derive?

Yes, unambiguously. The decomp is clear on all three questions:

Q: Should membership advance inside the sweep? Yes. validate_transition @ 0x0050aa70 is the sole gate for advancing curr_cell. Every accepted step updates curr_cell; every bounce resets check_pos to curr_pos without touching curr_cell. The current ResolveCellId(sp.GlobalSphere[0].Origin, ...) call in acdream's ResolveWithTransition (PhysicsEngine.cs:909/928) must be replaced by reading sp.CurCell (= sphere_path.curr_cell) directly, exactly as retail's SetPositionInternal does (pseudo_c:283403).

Q: Should the do_not_load_cells prune be added? Yes, but carefully. In retail it is set for PLACEMENT / TELEPORT paths (not for normal movement). For the physics SWEEP (normal movement), do_not_load_cells = 0. For SetPositionInternal placement path, do_not_load_cells = 1. acdream's CellTransit.FindCellList currently has no such prune — this is a latent source of spurious cross-wall cell candidates during teleports, but does NOT cause the per-frame ping-pong (since it's only for teleports). Port it as a separate step, after fixing the sweep tracking.

Q: Should render obey the physics curr_cell or a separate camera cell? Retail uses a separate camera cell (SmartBox::viewer_cell) computed by its own camera transition sweep — NOT player->cell directly. The current acdream U.4c fix (GameWindow.cs:7163) correctly uses the physics CurrCell as the PView root when the camera is also indoors, but this diverges from retail for 3rd-person where camera ≠ player body. The most retail-faithful fix is to run a camera cell transition sweep (the retail SmartBox::update_viewer path) and use THAT result as the PView root, falling back to the physics cell when the camera sweep fails.

For the current flicker/flap bugs, the U.4c fix (root at player cell, not camera eye) is empirically correct because acdream's camera doesn't yet have full collision. The retail-faithful end state requires a camera cell transition sweep.

Q: Should the render obey a single portal-visibility traversal? Yes. Retail's PView::ConstructView + DrawCells is a single BFS that produces the complete cell_draw_list and outside_view in one pass. There is no separate "inside pass" + "outside pass" split. The outdoor terrain draws INSIDE DrawCells when outside_view.view_count > 0 (i.e., exit portals were traversed). acdream's two-pipe architecture (WorldBuilder RenderInsideOut stencil + outdoor terrain) is the source of the seam bugs and should be replaced with a faithful PView BFS.

D16. Must-port functions, integration order, risks, conformance tests

Must-port functions (with retail addresses)

Priority Function Address Purpose
P1 CTransition::validate_transition 0x0050aa70 Accept-or-reject gate: advances curr_cell on accept, resets on reject
P1 CObjCell::find_cell_list 0x0052b4e0 Candidate cell array + containing-cell detection
P1 CPhysicsObj::SetPositionInternal 0x00515330 Write-back: reads sphere_path.curr_cellchange_cell
P2 CTransition::check_other_cells 0x0050ae50 Cross-cell collision + updates check_cell from find_cell_list
P2 CEnvCell::find_transit_cells 0x0052c820 Expand cell array through portals (the EnvCell variant)
P2 CLandCell::find_transit_cells 0x00533800 Expand cell array from outdoor cells
P2 CEnvCell::check_building_transit 0x0052c5d0 Detect sphere entering building through CBldPortal
P3 PView::ConstructView 0x005a57b0 Render BFS: builds cell_draw_list from viewer_cell
P3 PView::InitCell 0x005a4b70 Initialize per-portal vis state for root cell
P3 PView::DrawCells 0x005a4840 Execute the draw list: terrain if outside_view > 0
P3 SmartBox::update_viewer 0x00453ce0 Camera cell tracking via its own transition sweep
P4 CEnvCell::find_visible_child_cell 0x0052dc50 Find which portal-neighbor contains a point (used by camera placement)

Step 1 — Physics cell tracking (P1, eliminates the flicker bug): In PhysicsEngine.ResolveWithTransition, after transition.FindTransitionalPosition() returns, read transition.SpherePath.CurCell directly. This is retail's sphere_path.curr_cell after validate_transition has run. Do NOT call ResolveCellId(sp.GlobalSphere[0].Origin, ...).

Specifically, in PhysicsEngine.cs:

  • Line ~909: replace ResolveCellId(sp.GlobalSphere[0].Origin, ...) with direct sp.CurCell?.ID ?? sp.CheckCellId
  • Same at line ~928 (the partial-move path)
  • Add logic analogous to SetPositionInternal: if transitCell == null, treat as "left visibility"; if transitCell != null, use it directly.

This is the highest-priority change. It should IMMEDIATELY stop the ping-pong because CurCell only changes when validate_transition accepts a step.

Step 2 — do_not_load_cells prune for teleports (P1 followup): For the CheckBuildingTransit and teleport placement paths, set do_not_load_cells = 1 in the CELLARRAY before calling find_cell_list. The prune logic (remove cells not in visible.stab_list) prevents spurious cross-wall candidates during placements.

Step 3 — Camera cell tracking (P3, for render correctness): Port SmartBox::update_viewer's camera transition sweep. This is a separate CTransition::find_valid_position call on a small viewer_sphere from the player position to the camera-sought position. The result's sphere_path.curr_cell becomes the PView root (viewer_cell). acdream's PhysicsCameraCollisionProbe and RetailChaseCamera are the existing hooks for this.

Step 4 — PView BFS render traversal (P3, eliminates indoor seam bugs): Replace the current WorldBuilder RenderInsideOut stencil + outdoor draw split with a faithful PView::ConstructView BFS producing a cell_draw_list. Gate terrain draw on outside_view.view_count > 0. Gate entity/particle draws on cell membership in cell_draw_list.

Main risks

Risk 1 — The sweep's CurCell may be null in edge cases. Retail's SetPositionInternal handles curr_cell == null as "leave visibility". acdream must add the same null-guard. If the transition has zero steps (no movement), CurCell should be pre-seeded from PhysicsBody.CellId at transition init (the begin_cell). Failure mode: null-ref crash on first frame of movement.

Risk 2 — check_cell vs curr_cell confusion in check_other_cells. check_other_cells sets check_cell (NOT curr_cell) to the find_cell_list result (pseudo_c:272761). curr_cell only advances in validate_transition. If Step 1 reads from the wrong field, the flicker comes back in a different form.

Risk 3 — Cellar-ramp issue (#98) may be a separate bug. The stale contact-plane hypothesis (CLAUDE.md) suggests the cellar-ascent bug is a separate issue (stale ramp contact plane causing spurious Z-drift, not a cell tracking bug). Step 1 does NOT fix that; it only eliminates the doorway ping-pong. Keeping issues separate is important.

Risk 4 — The do_not_load_cells prune and multi-cell BSP. The prune uses the CURRENT visible cell's stab_list. If acdream hasn't loaded all stab-list cells, the prune may incorrectly remove valid neighbor cells. Implement the prune only when stab_list is guaranteed to be fully populated (after grab_visible_cells).

Risk 5 — WorldBuilder rendering infrastructure vs PView. acdream's entire renderer is built on WB's model. Porting PView from scratch is a large change. The safer incremental path: keep WB infrastructure but replace the camera_inside_building two-pipe split with a single PView-BFS-driven cell_draw_list that controls which draw passes run.

Risk 6 — CObjCell::seen_outside field in acdream's data layer. The seen_outside flag must be populated from the DAT (it is serialized in CEnvCell::UnPack at pseudo_c:311044311057). Verify that acdream's EnvCell data class carries and surfaces this field. If not, dungeon vs indoor vs outdoor classification cannot be retail-faithful.

Conformance tests

  1. Ping-pong test: Place the physics sphere at the exact doorway boundary (8cm inside the indoor cell). Run 100 ticks of zero-movement resolve. Assert CurrCell does NOT change across ticks. (Currently fails with acdream's static re-derive.)

  2. Doorway crossing test: Move sphere from outdoor cell through a door into the indoor cell. Assert CurrCell transitions exactly once — not on the first frame the sphere overlaps the door frame, but on the first frame validate_transition accepts a step placing the sphere center inside the indoor cell's BSP.

  3. Blocked-step stability: Set up a sphere pressed against a wall (collision returns non-OK). Run 10 ticks. Assert CurrCell never changes.

  4. Dungeon no-terrain test: Place player in a dungeon cell with seen_outside = 0. Assert that the render pass does NOT draw terrain (no LScape::draw call or equivalent).

  5. Exit-portal terrain test: Place player in an indoor cell that has an exit portal (seen_outside != 0). Assert that terrain IS drawn, clipped to the portal opening.

  6. do_not_load_cells prune test: Teleport a physics body to a position inside an EnvCell whose PVS does not include an adjacent cell. Assert the adjacent cell's ID does NOT appear in the collision CELLARRAY.

  7. Camera cell tracking: Move the camera to a position inside a building (3rd-person mode). Assert viewer_cell equals a valid indoor CEnvCell. Move camera back to outdoor. Assert viewer_cell becomes null/landcell.


Appendix: Key Function Summary Table

Function Address Key behavior
CObjCell::find_cell_list 0x0052b4e0 Build candidate array + pick containing cell via point_in_cell
CEnvCell::find_transit_cells 0x0052c820 Expand array through CCellPortal (EnvCell-to-EnvCell)
CEnvCell::check_building_transit 0x0052c5d0 Detect sphere entering building via CBldPortal sphere test
CTransition::check_other_cells 0x0050ae50 Find containing cell after primary insert; update check_cell
CTransition::validate_transition 0x0050aa70 Accept step: curr_cell=check_cell; reject: reset check_pos
CTransition::transitional_insert 0x0050b6f0 Outer sweep loop driving insert_into_cell + check_other_cells
CPhysicsObj::SetPositionInternal 0x00515330 Write-back: sphere_path.curr_cell → change_cell
CPhysicsObj::change_cell 0x00513390 leave_cell(old) + enter_cell(new)
CPhysicsObj::enter_cell 0x00510ed0 Register obj in new cell's object_list; update cell pointer
CPhysicsObj::leave_cell 0x00510f50 Deregister obj from old cell's object_list
CellManager::ChangePosition 0x004559b0 High-level: update curr_cell + trigger landscape grab/release
SmartBox::update_viewer 0x00453ce0 Camera cell tracking via own CTransition sweep
SmartBox::RenderNormalMode 0x00453aa0 Master render gate: DrawInside vs LScape::draw on seen_outside
PView::ConstructView 0x005a57b0 BFS from viewer_cell: builds cell_draw_list + outside_view
PView::InitCell 0x005a4b70 Init per-portal vis masks for root cell
PView::DrawCells 0x005a4840 Execute draw list; terrain if outside_view.view_count > 0
CEnvCell::GetVisible 0x0052dc10 Lookup cell by id in visible_cell_table (loaded cells only)
CEnvCell::find_visible_child_cell 0x0052dc50 Find portal-neighbor containing a given point
CEnvCell::grab_visible_cells 0x0052e220 Load self + stab_list; conditionally grab landscape
CObjCell::GetVisible 0x0052ad40 Dispatch to CEnvCell::GetVisible or CLandCell::GetVisible
CLandCell::GetVisible 0x00532db0 Lookup landcell by id
CLandCell::add_all_outside_cells 0x00533630 Add all surrounding landcells to CELLARRAY (for outdoor)

Cross-references

  • ACE PhysicsObj.cs:11711211 confirms SetPositionInternal reads SpherePath.CurCell directly (not via position re-derive). ACE ObjCell.cs:335413 confirms the find_cell_list logic including do_not_load_cells (LoadCells flag in ACE, CellArray.cs:8). ACE Transition.cs:9841091 (ValidateTransition) matches the retail decomp's curr_cell / check_cell advance/reset pattern exactly.
  • WorldBuilder PortalRenderManager.cs uses a flat stencil approach (not PView BFS) — this is a confirmed divergence from retail's recursive portal clip system.
  • acdream PhysicsEngine.cs:909/928: the two ResolveCellId(sp.GlobalSphere[0].Origin, ...) calls are the specific lines to replace with sp.CurCell reads.
  • acdream GameWindow.cs:7163: the W2 UCG fix (using physics CurrCell as PView root) is directionally correct and should be kept, but note it is the player-cell root, not the retail camera-cell root.