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>
42 KiB
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)orrepo/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:32625–32671) 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:
-
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_cellKey invariant:
curr_cellonly advances whenvalidate_transitionaccepts the move (returnsOK_TSwithcheck_pos != curr_pos). A bounce/slide resetscheck_postocurr_poswithout touchingcurr_cell. -
CTransition::check_other_cells@0x0050ae50(pseudo_c:272717) — updatessphere_path.check_cellafterfind_cell_listpicks 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 272765This is what replaces
check_cellwith the correct new cell across a portal boundary during a sweep step. -
CPhysicsObj::SetPositionInternal@0x00515330(pseudo_c:283399) — the write-back from transition result toCPhysicsObj::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 283456CPhysicsObj::cellis only updated here, fromsphere_path.curr_celldelivered by the completed transition. There is NO intermediate re-derive from world position. -
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) callsCObjCell::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:
-
Seed the starting cell (lines 308751–308769):
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) -
Expand via
find_transit_cells(lines 308771–308786): 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 addsportal.other_cell(if resolved) or adds the portal's landcell side. ForCLandCell::find_transit_cells@0x00533800(pseudo_c:317603): callsadd_all_outside_cellsthenCSortCell::find_transit_cells. -
Pick the containing cell (
*containingCell, lines 308788–308827):*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 scanInterior cell wins over landcell — once a
CEnvCellis found to contain the point, the loop breaks. Outdoor landcells are not preferred over interior cells. -
do_not_load_cellsprune (lines 308829–308867):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 = 1AND the position is already indoors (id >= 0x100). The prune usesvisible'sstab_listarray — 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 callingfind_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 setdo_not_load_cells— the sweep is allowed to discover any adjacent cell.
CELLARRAY struct (acclient.h:31574–31580):
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 directional — curr_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:
-
Sweep-path tracking, not static lookup. The sweep starts at
curr_celland advancescheck_cellthrough portals only when the sphere physically crosses through a portal (detected byfind_transit_cells). A position jitter of ±8cm across a door frame does NOT causecurr_cellto flip — it would only flip ifvalidate_transitionaccepted a step that movedcheck_pospast the portal. -
point_in_cellsemantics. The containing-cell selection infind_cell_listusesCObjCell::point_in_cell(vtable+0x84) which forCEnvCell::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_cellCCellStruct::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. -
Interior cell wins the point_in_cell scan. In the containing-cell scan (pseudo_c:308814-308820), once an interior cell (
id >= 0x100) passespoint_in_cell, the loop breaks immediately. This ensures that even if the outdoor landcell ALSO passespoint_in_cell(because the player is standing ON a building's footprint), the indoor cell takes priority. -
check_cellis set from the collision result, not re-derived from world position. Aftercheck_other_cellsupdatescheck_cellto thefind_cell_listresult, the next iteration of the sweep uses THATcheck_cellas the source forinsert_into_cell. There is no re-derive from the player's world XYZ. -
Standing still: When
targetPos == currentPos, the transition has zero steps.find_transitional_positionreturns immediately (ACE Transition.cs:528–529).curr_cellis 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:309983–310030).
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:52332–52335):
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_array → find_cell_list. It contains
ALL cells whose BSPs the sphere might overlap (typically 1–4 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.buildingsarray referencesCBuildingObjinstances.- Each building's
CBuildingObjhasportals(array ofCBldPortal*) connecting it to the indoorCEnvCellgraph and to the outdoor landscape. - The
CLandCellfor that terrain square is always present; theCEnvCellcells of the building sit "on top" of it spatially but are physically separate cells. CEnvCell::seen_outside(acclient.h:30929, typeint) 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
CEnvCellgraph, loaded from the DAT as theCLandBlockInfofor the dungeon block. There is no terrainCLandCellin the dungeon landblock. - All
CEnvCells in a dungeon haveseen_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 samefind_transit_cellsmechanism.
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_listseeds fromCEnvCell::GetVisible(id)(id >= 0x100 branch), adds only the starting EnvCell.CEnvCell::find_transit_cellstests each portal. Exit portals withother_cell_id == 0xFFFFFFFFexist in surface buildings to reach the landscape; dungeon cells don't have such portals — their portals all connect to other dungeonCEnvCells.
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:94649–94661). 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:
-
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_listare never drawn. -
cell_draw_listis the only draw gate.PView::DrawCellsiterates onlycell_draw_list.data[0..cell_draw_num]. An entity or particle in a cell not incell_draw_listis not drawn. There is no separate frustum cull — portal visibility IS the culling. -
Ceiling/floor capping: The
CCellStructBSP for eachCEnvCellincludes all surfaces (floor, ceiling, walls). WhenDrawEnvCellrenders the cell geometry, all surfaces including the ceiling are drawn. The only surfaces NOT drawn are portals (they getDrawPortalPolyInternaltreatment instead of normal polygon rendering) — pseudo_c:432785–432791: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.
-
Entity draw:
DrawCellsalso callsDrawObjCellForDummies(pseudo_c:432878) for each cell incell_draw_list, which draws entities registered in that cell'sobject_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:92665–92684):
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::DrawCellsnever setsoutside_view.view_count > 0, soLScape::drawis never called. - Building cell with exit portals: exit portal traversal adds an entry to
outside_view;LScape::drawfires insideDrawCells.
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:92761–92892) is called every frame. It:
- Reads
this->player->cell(the physics cell). - If
player->cell == null: callsreenter_visibility, setsviewer_cell = null. - Otherwise, runs a camera position transition:
CTransition::find_valid_position(line 92868) sweeps theviewer_spherefromplayer_postoviewer_sought_position(the 3rd-person camera target position). - Sets
viewer_cell = transition.sphere_path.curr_cell(line 92871) — directly from the camera transition result, NOT from re-deriving by position. - If the camera transition fails: falls back to
CPhysicsObj::AdjustPositionon the viewer sphere, settingviewer_cellfrom that result. - 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 bySetPositionInternalSmartBox::viewer_cell— render root, updated per-frame byupdate_viewervia 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.
D. Synthesis and Recommended acdream Architecture
D14. Retail-faithful target architecture
Given what the decomp shows, the correct architecture is:
Physics side (cell membership):
sphere_path.curr_celltracks the player's cell THROUGH the sweep.curr_celladvances only whenvalidate_transitionaccepts a step.- Blocked/standing-still steps never change
curr_cell. - After the sweep,
SetPositionInternalwritessphere_path.curr_cell→CPhysicsObj::cell. - No static re-derive from world position after the sweep.
Camera/render side (viewer cell):
SmartBox::viewer_cellis resolved via its OWNCTransition::find_valid_positionsweep on the camera eye sphere each frame.- This camera sweep returns its own
sphere_path.curr_cellwhich becomesviewer_cell. viewer_cellis what the PView traversal roots from — notplayer->cell.- The PView traversal (ConstructView → BFS → cell_draw_list) computes the entire visible
set from
viewer_cellin 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_cell → change_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) |
Integration order (recommended)
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 directsp.CurCell?.ID ?? sp.CheckCellId - Same at line ~928 (the partial-move path)
- Add logic analogous to
SetPositionInternal: iftransitCell == null, treat as "left visibility"; iftransitCell != 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:311044–311057). 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
-
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
CurrCelldoes NOT change across ticks. (Currently fails with acdream's static re-derive.) -
Doorway crossing test: Move sphere from outdoor cell through a door into the indoor cell. Assert
CurrCelltransitions exactly once — not on the first frame the sphere overlaps the door frame, but on the first framevalidate_transitionaccepts a step placing the sphere center inside the indoor cell's BSP. -
Blocked-step stability: Set up a sphere pressed against a wall (collision returns non-OK). Run 10 ticks. Assert
CurrCellnever changes. -
Dungeon no-terrain test: Place player in a dungeon cell with
seen_outside = 0. Assert that the render pass does NOT draw terrain (noLScape::drawcall or equivalent). -
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. -
do_not_load_cellsprune 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 collisionCELLARRAY. -
Camera cell tracking: Move the camera to a position inside a building (3rd-person mode). Assert
viewer_cellequals a valid indoorCEnvCell. Move camera back to outdoor. Assertviewer_cellbecomes 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:1171–1211confirmsSetPositionInternalreadsSpherePath.CurCelldirectly (not via position re-derive). ACEObjCell.cs:335–413confirms thefind_cell_listlogic includingdo_not_load_cells(LoadCellsflag in ACE, CellArray.cs:8). ACETransition.cs:984–1091(ValidateTransition) matches the retail decomp's curr_cell / check_cell advance/reset pattern exactly. - WorldBuilder
PortalRenderManager.csuses 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 twoResolveCellId(sp.GlobalSphere[0].Origin, ...)calls are the specific lines to replace withsp.CurCellreads. - acdream
GameWindow.cs:7163: the W2 UCG fix (using physicsCurrCellas PView root) is directionally correct and should be kept, but note it is the player-cell root, not the retail camera-cell root.