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>
59 KiB
Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering
Study author: Opus 4.8 (1M ctx), researcher "opus48-b"
Date: 2026-06-02
Primary oracle: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR build, PDB-named)
Cross-checks: references/ACE/Source/ACE.Server/Physics/*, acdream current code, acclient.h verbatim structs.
Citation convention.
Class::method @ 0xADDR (pc:LINE)cites the named pseudo-C at the given address and line.repo/path:LINEcites a reference repo. VERIFIED = I read it in source. INFER = a reasoned conclusion not directly stated. Where ACE and the decomp agree, I say so; where I could not confirm something, I flag it.
Executive summary (read this first)
Retail tracks "the cell I'm in" as one value — SPHEREPATH::curr_cell — that is carried through the
collision sweep and committed to CPhysicsObj::cell only when it actually changes. It is never
re-derived from the final resting position. The single mechanism that makes cell membership stable at
doorways is the trio (a) accept-cell-on-successful-move inside validate_transition
(curr_cell = check_cell only when the move was OK and the position actually changed), (b) the
directional containing-cell picker in find_cell_list (interior cells win, first interior hit breaks),
and (c) the do_not_load_cells prune that removes any candidate cell that is neither the current cell
nor in its visible/stab list. A blocked or standing-still step explicitly reverts to curr_pos/curr_cell,
so it cannot flip the cell.
Rendering is the same cell graph. The render's "camera cell" (SmartBox::viewer_cell) is produced by
running a second transition (the camera spring-arm) and reading its sphere_path.curr_cell
(SmartBox::update_viewer @ 0x453CE0, pc:92871). The visible set is built by one portal-visibility BFS
(PView::ConstructView), and the outside is drawn seamlessly through a doorway because exit portals
(those whose other_cell_id == 0xFFFF) contribute a clip region to an outside_view that triggers
LScape::draw clipped to the doorway extent (PView::ClipPortals @ 0x5A5520 pc:433662-433685;
PView::DrawCells @ 0x5A4840 pc:432715-432719). There is no separate "inside / outside" stencil pass
and no independent render cell system in retail.
acdream's flicker is caused by exactly the thing retail does not do: after running the (correct) swept
transition, PhysicsEngine.ResolveWithTransition throws away the swept cell (sp.CheckCellId/
sp.CurCellId) and calls ResolveCellId(sp.GlobalSphere[0].Origin, …) to re-derive membership from the
final static origin (PhysicsEngine.cs:909 and :928). Because collision push-back jitters that origin
±~8 cm across a cell boundary, the re-derive oscillates. The fix is small and low-risk: return the swept
cell. acdream already advances sp.CurCellId = sp.CheckCellId inside its ValidateTransition
(TransitionTypes.cs:3408) — the machinery exists; only the consumer is wrong. The render-side fix is to
root visibility at the physics cell (W2 already adds ComputeVisibilityFromRoot) and to draw the landscape
clipped to exit-portal regions (retail outside_view + LScape::draw).
A. Cell membership & transitions (physics)
A0. The data: how "the cell I'm in" is stored
There are three distinct cell pointers, on two objects:
On CPhysicsObj (the committed, between-frames truth):
CPhysicsObj::cell— the object's committed current cell.acclient.h(the object is large;cellis the resident cell pointer set byenter_cell/leave_cell).CPhysicsObj::m_position.objcell_id— the committed cell id (low 16 bits = cell-within-landblock, high 16 = landblock).
On SPHEREPATH (the per-transition working state): SPHEREPATH struct verbatim
(acclient.h:32625-32671, VERIFIED):
struct SPHEREPATH {
unsigned int num_sphere;
CSphere *local_sphere; ... CSphere *global_sphere; ...
AC1Legacy::Vector3 *global_curr_center; // current sphere center (advances per sub-step)
...
CObjCell *begin_cell; Position *begin_pos; Position *end_pos;
CObjCell *curr_cell; Position curr_pos; // the ACCEPTED cell + pos so far
AC1Legacy::Vector3 global_offset;
int step_up; ... int collide;
CObjCell *check_cell; Position check_pos; // the CANDIDATE cell + pos being tested
SPHEREPATH::InsertType insert_type;
int step_down; ...
CObjCell *backup_cell; Position backup_check_pos;
int obstruction_ethereal;
int hits_interior_cell; // set when the candidate set touches an EnvCell
int bldg_check;
...
int cell_array_valid; // is the cached CELLARRAY still good for this check_pos?
...
};
The mental model: curr_cell is "where I have validly reached so far"; check_cell is "the cell of the
position I'm trying next." The transition advances check_*, tests it, and on success promotes it into
curr_*. At the very end, the committed CPhysicsObj::cell is synced from sphere_path.curr_cell.
CELLARRAY (the collision candidate set) is a fourth thing, separate from curr_cell — see A5.
CELLARRAY verbatim (acclient.h:31574-31580, VERIFIED):
struct CELLARRAY {
int added_outside; // guards add_all_outside_cells (add outdoors once per build)
int do_not_load_cells; // the prune flag (see A2)
unsigned int num_cells;
DArray<CELLINFO> cells; // CELLINFO = { uint cell_id; CObjCell* cell; } (acclient.h:31925)
};
A1. The full update chain (per physics tick)
I traced the chain end-to-end. VERIFIED at every step:
CPhysicsObj::UpdateObjectInternal (per-tick body, ~pc:283600+)
└─ UpdatePositionInternal @ 0x512C30 (pc:280817) // compute desired Frame offset
└─ eax_10 = CPhysicsObj::transition(this, m_position, dest, 0) @ 0x512DC0 (pc:280904)
│ └─ CTransition::init_path(result, this->cell, begin, end) @ 0x509E60 (pc:271982)
│ │ └─ SPHEREPATH::init_path @ 0x50CE20 (pc:274359):
│ │ curr_cell = begin_cell = this->cell; curr_pos = begin_pos; // SEED
│ └─ CTransition::find_valid_position @ 0x50C310 (pc:273890)
│ └─ (TRANSITION_INSERT) CTransition::find_transitional_position @ 0x50BDF0 (pc:273613)
│ └─ FOR each of var_48 sub-steps:
│ check_pos += global_offset // advance candidate
│ var_44 = validate_transition(transitional_insert(this,3), &redo)
│ ▲ transitional_insert @ 0x50B6F0 (pc:273137) // the stepper
│ ▲ validate_transition @ 0x50AA70 (pc:272547) // accept/advance
└─ if (eax_10 != 0) CPhysicsObj::SetPositionInternal(this, eax_10) @ 0x515330 (pc:283696)
└─ curr_cell = arg2->sphere_path.curr_cell; // READ the swept cell
└─ if (this->cell != curr_cell) change_cell(this, curr_cell); // COMMIT only on change
└─ set_frame(this, &arg2->sphere_path.curr_pos.frame); // commit position
The per-tick body at pc:283673 (VERIFIED): class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); then pc:283696: CPhysicsObj::SetPositionInternal(this, eax_10);. The
cell that ends up on the object is read straight out of the transition's sphere_path.curr_cell. No static
re-derive is performed anywhere in this chain.
A1.1 transitional_insert — the sub-step stepper
CTransition::transitional_insert @ 0x50B6F0 (pc:273137, VERIFIED). For up to arg2 insertion attempts it:
edi = insert_into_cell(this, sphere_path.check_cell, arg2)(pc:273153) — collide the candidate sphere againstcheck_cell's BSP.- On
OK_TS:edi = check_other_cells(this, sphere_path.check_cell)(pc:273161) — test every other cell the sphere overlaps (viafind_cell_list) and retargetcheck_cellto the containing cell. - Switch on the state:
COLLIDED_TSreturns (blocked);ADJUSTED_TS/SLID_TSclearneg_poly_hitand continue; onOK_TSit handles step-down / edge-slide / slide-sphere.
Key: check_other_cells is where, mid-sweep, the candidate cell is reassigned to the cell that
actually contains the swept sphere center. So as the sphere crosses a portal during the sweep, check_cell
follows it cell-by-cell.
A1.2 validate_transition — accept the move and advance curr_cell
CTransition::validate_transition @ 0x50AA70 (pc:272547, VERIFIED). This is the linchpin. Structure:
result = arg2; // the TransitionState from transitional_insert
if (result != OK_TS) { // ── blocked / slid / adjusted ──
if (result in (OK_TS, SLID_TS]) { // collided/adjusted/slid
... restore last-known contact plane, kill velocity ...
set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // REVERT (pc:272593)
build_cell_array(this, nullptr);
result = OK_TS;
}
} else { // ── OK ──
if (check_pos.objcell_id == curr_pos.objcell_id // (same cell &&
&& Frame::is_equal(check_pos.frame, curr_pos.frame)) // same frame) → no movement
goto done; // accept as-is, do NOT advance
// else: real movement → PROMOTE check → curr:
label_50aba9:
curr_pos.objcell_id = check_pos.objcell_id; // (pc:272610)
curr_pos.frame = check_pos.frame;
curr_cell = check_cell; // *** ADVANCE MEMBERSHIP *** (pc:272612)
cache_global_curr_center(&sphere_path);
// reset check_* = curr_* for next sub-step:
check_pos.objcell_id = curr_pos.objcell_id;
check_pos.frame = curr_pos.frame;
check_cell = curr_cell;
}
The two guarantees that kill flicker live here, VERIFIED:
- Blocked/slid path (pc:272593):
set_check_pos(curr_pos, curr_cell)— the candidate is thrown away and reset to the current (last-accepted) position/cell. A wall bump does not changecurr_cell. - OK-but-didn't-move path (pc:272600-272605): if
check_pos == curr_pos(same id and frame),goto done— no promotion. Standing still does not changecurr_cell. - OK-and-moved path (pc:272608-272619): only here is
curr_cell = check_cellexecuted.
ACE cross-check (agrees exactly): Transition.ValidateTransition (ACE Transition.cs:984).
On transitionState != OK and not Invalid, it calls SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell) (ACE Transition.cs:1014) — revert. On OK, SetCurrentCheckPos() (ACE
Transition.cs:1084-1091) does SpherePath.CurPos = CheckPos; SpherePath.CurCell = SpherePath.CheckCell;
— advance. The gate is transitionState != OK || CheckPos.Equals(CurPos) (ACE Transition.cs:990). Same
logic, same membership advance.
A1.3 SetPositionInternal(CTransition) — commit, only on change
CPhysicsObj::SetPositionInternal @ 0x515330 (pc:283399, VERIFIED):
curr_cell = arg2->sphere_path.curr_cell; // (pc:283403)
if (curr_cell == 0) { ... GotoLostCell ... } // left the world
else {
if (this->cell == curr_cell) { // SAME cell → just refresh ids (pc:283414)
this->m_position.objcell_id = sphere_path.curr_pos.objcell_id;
... SetCellID on parts/children ...
} else
CPhysicsObj::change_cell(this, curr_cell); // DIFFERENT cell → leave+enter (pc:283456)
CPhysicsObj::set_frame(this, &sphere_path.curr_pos.frame);
... copy contact_plane, transient_state from transition ...
}
change_cell only fires when this->cell != curr_cell. Since curr_cell came from validate_transition
(stable across blocks/standing-still), the committed cell is stable too.
CPhysicsObj::change_cell @ 0x513390 (pc:281192, VERIFIED): if (this->cell) leave_cell(this,1); if (arg2) enter_cell(this, arg2); else { m_position.objcell_id = 0; cell = null; }. leave_cell/enter_cell
manage the cell's shadow_object_list/object_list membership and part-array cell ids.
A2. find_cell_list — building the candidate array & picking the containing cell
CObjCell::find_cell_list has several overloads. The one used everywhere through the sweep is the
3-arg forwarder find_cell_list(CELLARRAY*, CObjCell** out, SPHEREPATH*) @ 0x52B960 (pc:309085) which
forwards to the master overload find_cell_list(Position, num_sphere, CSphere, CELLARRAY, CObjCell** out, SPHEREPATH) @ 0x52B4E0 (pc:308742), passing check_pos, num_sphere, global_sphere.
Master overload, VERIFIED (pc:308742-308869). Annotated:
edi = arg4; // CELLARRAY
edi->num_cells = 0;
edi->added_outside = 0;
objcell_id = arg1->objcell_id; // the position's current cell id
visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // indoor
: CLandCell::GetVisible(objcell_id); // outdoor
// (1) seed the array with the current cell (indoor) or the outdoor landcells:
if (objcell_id >= 0x100) { // INDOOR
if (arg6) arg6->hits_interior_cell = 1;
CELLARRAY::add_cell(edi, objcell_id, visibleCell);
} else // OUTDOOR
CLandCell::add_all_outside_cells(arg1, num_sphere, sphere, edi); // (pc:308769)
if (visibleCell != 0 && num_sphere != 0) {
// (2) EXPAND: each cell contributes its transit neighbors (portals / building portals / outside):
for (i in 0..num_cells)
edi->cells[i].cell->vtable->find_transit_cells(arg1, num_sphere, sphere, edi, arg6); // +0x80
// (3) PICK the single containing cell into *arg5:
if (arg5) {
*arg5 = null;
for (i in 0..num_cells) {
cell = edi->cells[i].cell;
blockOffset = LandDefs::get_block_offset(arg1->objcell_id, cell.id);
localCenter = sphere.center - blockOffset;
if (cell->vtable->point_in_cell(&localCenter)) { // +0x84
*arg5 = cell;
if ((cell.id & 0xFFFF) >= 0x100) { // INTERIOR cell wins:
if (arg6) arg6->hits_interior_cell = 1;
break; // *** first interior hit wins ***
}
// outdoor hit: keep scanning (an interior cell may still contain the point)
}
}
}
// (4) PRUNE (do_not_load_cells), only when currently in an interior cell:
if (edi->do_not_load_cells && (arg1->objcell_id & 0xFFFF) >= 0x100) {
for (i in 0..num_cells) {
cell_id = edi->cells[i].cell_id;
if (cell_id == visibleCell->m_DID.id) continue; // keep the current cell
found = false;
for (stab in visibleCell->stab_list[0..num_stabs]) if (cell_id == stab) { found=true; break; }
if (!found) CELLARRAY::remove_cell(edi, i); // drop "stranger" cells
}
}
}
The arg4 + 0x28 / arg4 + 0xe0/+0xe4 field offsets in the raw decomp (pc:308839, 308846, 308851) resolve
to visibleCell->m_DID.id and visibleCell->num_stabs/visibleCell->stab_list — confirmed by
CObjCell layout (acclient.h:30927-30928: unsigned int num_stabs; unsigned int *stab_list;).
ACE cross-check (agrees exactly): ObjCell.find_cell_list (ACE ObjCell.cs:335-414).
- Picker breaks on first interior cell containing the point (ACE
ObjCell.cs:378-382). - Prune:
if (!cellArray.LoadCells && (position.ObjCellID & 0xFFFF) >= 0x100)removes any cell that is neithervisibleCell.IDnor in((EnvCell)visibleCell).VisibleCells(ACEObjCell.cs:387-413). (ACE inverts the name:LoadCells == !do_not_load_cells.)
A2.1 What do_not_load_cells is, when it's set, what it buys
What it is: a flag on the CELLARRAY that, when set, restricts the candidate cell set to (the current
cell) ∪ (its visible/stab list). The "stab list" of a CEnvCell is the set of cell ids the dat marks as
visible/reachable from that cell (CObjCell::stab_list, also driving find_visible_child_cell and the
render's add_views). Outdoor landcells are never in an interior cell's stab list, so the prune drops
them.
When it's set: CPhysicsObj::SetPositionInternal(Position, SetPositionStruct, CTransition) @ 0x515BD0
(pc:283929-283930, VERIFIED): if ((arg3->flags & 0x20) != 0) edi->cell_array.do_not_load_cells = 1;.
i.e., it's a per-call option keyed on SetPositionStruct flag 0x20. This flag is set by callers that move
the object without wanting new cells streamed in / without crossing out of the known cell set — most
relevantly authoritative position teleports and constrained sets where the server already told us the cell.
INFER (medium confidence): during ordinary frame movement (CPhysicsObj::transition) the flag is not
set, so the prune does not run on every walk-tick; it's specifically a stability guard for set-position
operations. The flicker-killing for ordinary walking comes from A1.2's accept-on-move + A2's directional
picker, not from the prune. The prune's stability value is: when you ask "which cell is this position in?"
during a constrained set, you never accidentally promote into an outdoor landcell or a far interior cell
just because the foot sphere clipped its bounding volume.
INFER: acdream's analogue would set this for server UpdatePosition and any "snap to known cell" path,
not for free movement. (acdream currently has no do_not_load_cells — it instead bolts a DoorwayHoldMargin
hysteresis onto the static re-derive; see D.)
A3. Precisely how retail avoids cell flicker (the answer)
It is a combination, with the dominant mechanism being swept-path containment with accept-on-move:
-
Membership is carried, not re-derived.
curr_cellpersists across ticks viaCPhysicsObj::celland is only ever changed insidevalidate_transitionon a successful, position-changing sub-step (pc:272612). A tick that ends blocked or standing-still leavescurr_cellexactly where it was (pc:272593, pc:272600-272605). This is the property acdream lacks — acdream recomputes from the static origin every tick. -
The picker is directional/priority-ordered. When the candidate set is rebuilt (mid-sweep via
check_other_cells, or on ado_not_load_cellsset),find_cell_listbreaks on the first interior cell that contains the point (pc:308814-308819). Interior cells dominate outdoor cells. So at the threshold, as long as the foot sphere's center is inside the vestibule'scell_bsp, the vestibule wins even though the outdoor landcell also overlaps the sphere. -
point_in_cellis a precise BSP/leaf test, not a bounding-box test.CEnvCell::point_in_cell @ 0x52C300(pc:309677, VERIFIED): transforms the global point into the cell's local frame (Frame::globaltolocal) thenCCellStruct::point_in_cell(structure, localPoint)— a test against the cell'scell_bspleaf volume (CCellStruct.cell_bsp,acclient.h:32289).CLandCell::point_in_cell @ 0x52D40(pc:316941) testsfind_terrain_poly— the point is in the landcell iff a terrain triangle contains it. Becausepoint_in_cellis exact, the "containing cell" is unambiguous for a given center. -
The
do_not_load_cellsprune (A2.1) is the additional guard for set-position; it removes stranger cells from the candidate array so a constrained set cannot drift the cell.
The flicker acdream sees (0xA9B40170 ↔ 0xA9B40031 at a static position) is structurally impossible in
retail: retail would have committed curr_cell = 0xA9B40170 once (when the sweep that crossed the doorway
succeeded), and every subsequent standing-still tick hits validate_transition's "didn't move → don't
promote" branch (pc:272600-272605), so the cell never re-evaluates against the jittered origin at all.
A4. Transitions: indoor↔outdoor, interior↔interior; CCellPortal vs CBldPortal
Two portal types, two directions:
A4.1 CCellPortal (interior↔interior, and interior→exterior)
CCellPortal verbatim (acclient.h:32300-32308, VERIFIED):
struct CCellPortal {
unsigned int other_cell_id; // 0xFFFFFFFF (==0xFFFF low) → EXTERIOR portal (leads outside)
CEnvCell *other_cell_ptr; // resolved neighbor (or null)
CPolygon *portal; // the portal polygon (its plane = the doorway plane)
int portal_side; // which half-space is "inside"
int other_portal_id;
int exact_match;
};
CCellPortal::GetOtherCell @ 0x53BA30 (pc:324830, VERIFIED) = CEnvCell::GetVisible(other_cell_id).
Interior→interior expansion is in CEnvCell::find_transit_cells @ 0x52C820 (pc:309968, VERIFIED): for
each of the cell's portals[]:
other = CCellPortal::GetOtherCell(portal). If non-null and the sphere intersectsother->structure(CCellStruct::sphere_intersects_cell != OUTSIDE, pc:310052),CELLARRAY::add_cell(other)(pc:310054).- If
other == null(an exterior portal,other_cell_id == 0xFFFF), it instead does a plane-distance test of the sphere against the portal poly; if the sphere is on/through the portal it sets a local flagvar_44(pc:310099). After processing all portals,if (var_44) CLandCell::add_all_outside_cells(...)(pc:310119-310120) — this is how the outdoor landcells enter the physics candidate set when the player is at/through an exit doorway, so collision against outdoor terrain works at the threshold.
ACE cross-check: EnvCell.find_transit_cells (ACE EnvCell.cs:311-370) — same: portal loop, sphere
intersect test, and LandCell.add_all_outside_cells at the end (ACE EnvCell.cs:370).
A4.2 CBldPortal (exterior→interior building entry)
CBldPortal verbatim (acclient.h:32094-32103, VERIFIED):
struct CBldPortal {
int portal_side;
unsigned int other_cell_id; // the interior EnvCell this building portal leads into
int other_portal_id;
int exact_match;
unsigned int num_stabs;
unsigned int *stab_list;
float sidedness;
};
When the player is in an outdoor landcell, the landcell's CSortCell may hold a CBuildingObj.
CLandCell::find_transit_cells @ 0x533800 (pc:317603, VERIFIED): add_all_outside_cells(...) then
CSortCell::find_transit_cells(...) (pc:317607) → CBuildingObj::find_building_transit_cells @ 0x6B5230
(pc:701214, VERIFIED): for each building portal, other = CBldPortal::GetOtherCell(portal)
(= CEnvCell::GetVisible(other_cell_id), pc:325003), and if non-null,
CEnvCell::check_building_transit(other, portal->other_portal_id, ...) (pc:701227).
CEnvCell::check_building_transit @ 0x52C5D0 (pc:309827, VERIFIED): if the sphere intersects the interior
cell's structure (sphere_intersects_cell != OUTSIDE), it add_cells the interior EnvCell and sets
sphere_path->hits_interior_cell = 1 (pc:309857-309860). This is the outdoor→indoor entry: standing
outside, when your foot sphere pokes through a building's door portal, the interior cell joins the candidate
set, the directional picker (A3.2) prefers it (interior wins), and curr_cell advances into the building on
the next successful sub-step.
CSortCell : CObjCell { CBuildingObj* building } (acclient.h:31880-31883); CBuildingObj : CPhysicsObj { num_portals; CBldPortal** portals; num_leaves; CPartCell** leaf_cells; ... } (acclient.h:31908-31916).
A4.3 indoor→outdoor (exit) resolution at set-position time
CPhysicsObj::AdjustPosition @ 0x511D80 (pc:280009, VERIFIED) is the initial cell resolver used by
SetPositionInternal(Position,…). For an indoor id it:
eax_5 = CObjCell::GetVisible(objcell_id).eax_6 = CEnvCell::find_visible_child_cell(eax_5, globalPoint, arg5)(pc:280028) — find the exact child cell containing the point (via stab list or portals).- If found → use it (pc:280032).
- If not found AND
eax_5->seen_outside != 0(pc:280037) →Position::adjust_to_outside(arg1)(pc:280039) andGetVisible(outsideId)— the indoor→outdoor exit: when the point is no longer in any reachable interior child cell and the cell can see outside, convert to the outdoor landcell.
CObjCell::seen_outside (acclient.h:30929, VERIFIED) is the per-cell flag "this cell has an exterior
portal / can reach the open world."
check_other_cells has the mid-sweep version of the same exit (pc:272772-272795): when no candidate cell
contains the swept center and the id < 0x100 path applies, it calls LandDefs::adjust_to_outside and resets
check_cell = null with the outdoor id, letting the next sub-step land in the outdoor landcell.
A5. Is the cell ARRAY the same as curr_cell? — No, they're two things, related per-transition
curr_cell(and committedCPhysicsObj::cell) = membership — the single answer to "which cell am I in." One pointer. Advanced only byvalidate_transition.CELLARRAY(CTransition::cell_array) = the collision candidate set — every cell whose BSP/geometry the swept sphere must be tested against this sub-step (the current cell + portal neighbors + outdoor landcells if a doorway is straddled + building interiors if a building portal is straddled). Many cells. Rebuilt byfind_cell_listeach timecell_array_valid == 0.
How they relate within one transition: find_cell_list does both jobs in one pass — it fills the
CELLARRAY (for collision) and writes the single containing cell into *arg5 (the membership candidate).
check_other_cells @ 0x50AE50 (pc:272717, VERIFIED) calls find_cell_list(cell_array, &var_4c, sphere_path),
collides the sphere against every array cell except the current one (cell->vtable[+0x88](this) =
find_collisions, pc:272735), and on success sets sphere_path.check_cell = var_4c (the containing cell,
pc:272760-272761). So: the array drives collision; the picked element (var_4c) becomes the next
check_cell, which validate_transition then promotes to curr_cell. Two mechanisms, one shared builder.
B. Underground / dungeons
B6. Representation: dungeons vs building interiors
Both dungeons and building interiors are EnvCell graphs (CEnvCell with structure, portals,
static_objects), but they differ in their relationship to the landblock and terrain:
-
Building interior (cottage/inn): the EnvCells sit on a landblock that has terrain. They are reached from the open world via a
CBuildingObj'sCBldPortals (A4.2). Some of theirCCellPortals are exterior portals (other_cell_id == 0xFFFF) — the doorways/windows that see the outdoors.seen_outsideis true for cells with such portals. The landblock'sCLandBlockInfo(acclient.h:31893-31905, VERIFIED) carriesnum_cells; cell_ids; CEnvCell** cells;(the interior cells) andnum_buildings; BuildInfo** buildings;(the buildings) alongsidecell_ownershipand arestriction_table. -
Dungeon: a self-contained EnvCell graph (often its own landblock with the
0x..FF"all-cells" range) with no exterior portals and no terrain (CLandCellfor that landblock is degenerate).seen_outsideis false for dungeon cells. INFER (high confidence): the engine "knows there's no sky/terrain" not via a dedicated underground flag but because the camera cell is anCEnvCellwhose reachable graph contains no exit portal — soPView'soutside_view.view_countstays 0 andLScape::drawis never invoked through a portal (see C). The dat-level distinction is the absence of exterior portals /seen_outside == 0, not a boolean "underground."
CCellStruct (the per-cell geometry, acclient.h:32275-32290, VERIFIED) carries everything a cell needs:
vertex_array; num_portals; CPolygon** portals; surface_strips; polygons; drawing_bsp; physics_polygons; physics_bsp; cell_bsp;. Note the three BSPs: drawing_bsp (render), physics_bsp
(collision against cell geometry), cell_bsp (point/sphere-in-cell containment tests). A dungeon cell and a
building interior cell are the same struct; only their portal topology and seen_outside differ.
B7. Moving through a dungeon: cell tracking, loading, no-terrain
- Cell tracking is identical to A1-A3:
transition→validate_transitionadvancescurr_cellacrossCCellPortals (interior→interior, A4.1). The only difference is that no portal is an exterior portal, sofind_transit_cellsnever callsadd_all_outside_cells(itsvar_44flag stays 0). - Loading/streaming:
CEnvCell::GetVisible @ 0x52DC10(pc:311378, VERIFIED) andCObjCell::GetVisible @ 0x52AD40(pc:308209, dispatch by id magnitude: ≥0x100 →CEnvCell::GetVisible, elseCLandCell::GetVisible). EnvCells are fetched/built on demand;CEnvCell::PreFetchCells @ 0x52C460(pc:309754, VERIFIED) prefetches the cell'sstab_list-reachable cells and, only ifseen_outside, the surrounding landblock (LScape::PreFetchCells(m_DID.id | 0xFFFF), pc:309759). For a dungeon (seen_outside == 0) the surrounding landscape is never prefetched — confirming there is no terrain to stream/draw. - No-sky/terrain knowledge: see B8 + C12.
B8. Is there an explicit "underground" flag?
Mostly no — it's derived. I found no boolean is_underground on Position, landblock, or cell. The
operative field is CObjCell::seen_outside (acclient.h:30929, VERIFIED). The render decision (C12) keys on
"is the viewer cell an CEnvCell, and does it / its reachable graph have an exterior portal?":
SmartBox::RenderNormalMode @ 0x453AA0(pc:92649, VERIFIED) computesebx_1 = (outdoor_view || viewer_cell->seen_outside)to decide whether to update the landscape viewpoint at all.PViewaccumulates exterior-portal clip regions intooutside_view; ifoutside_view.view_count == 0(no exit portal was visible — i.e., a sealed dungeon),LScape::drawis skipped inDrawCells(pc:432715, VERIFIED). So "underground" ≡ "currentCEnvCellreachable graph yields no visible exterior portal," which makes terrain+sky drop out naturally.
There is also the cell-id magnitude convention itself: low-16 >= 0x100 ⇒ this id names an CEnvCell
(interior), < 0x100 ⇒ a CLandCell (outdoor surface cell of a landblock). This is the type discriminator
used everywhere (find_cell_list pc:308753, GetVisible pc:308209), but it does not by itself mean
"underground" — a cottage interior is >= 0x100 too. Underground is the further refinement
seen_outside == 0.
C. Rendering inside and outside (the seamless seal)
C9. The single-pass visible-set build (ConstructView / InitCell / PView)
Retail's interior render is PView ("portal view"). The whole thing is one portal-flood BFS over the
shared CEnvCell graph. Top-level entry when the camera is inside a cell:
PView::DrawInside @ 0x5A5860 (pc:433793, VERIFIED):
CEnvCell::curr_view_push(arg2); // push this cell's view stack
PView::add_views(this, arg2->num_stabs, arg2->stab_list); // pre-push stab-list cells (pc:433801)
Render::copy_view(arg2->portal_view.data[num_view-1], null, 4); // seed the camera's view
edx_2 = PView::ConstructView(this, arg2, 0xFFFF); // *** build visible set ***
PView::DrawCells(this, edx_2); // *** draw it ***
PView::remove_views(this, arg2->num_stabs, arg2->stab_list);
arg2->num_view -= 1;
PView::ConstructView(CEnvCell, portal_id) @ 0x5A57B0 (pc:433750, VERIFIED) — the BFS:
outside_view.view_count = 0; // reset the "outside seen through a portal" accumulator
master_timestamp += 1;
cell_todo_num = 0; cell_draw_num = 0;
InitCell(this, arg2, portal_id); // compute per-portal in/out flags for the start cell
InsCellTodoList(this, arg2, 0); // seed the worklist
while (cell_todo_num > 0) {
cell = pop(cell_todo_list);
if (cell == 0) break;
cell_draw_list[cell_draw_num++] = cell; // add to OUTPUT
cell->portal_view[num_view-1]->cell_view_done = 1;
if (ClipPortals(this, cell, 0)) // clip this cell's portals to the view
AddViewToPortals(this, cell); // enqueue visible neighbor cells
}
PView::InitCell @ 0x5A4B70 (pc:432896, VERIFIED): for each portal of the cell, it computes the portal
plane's side relative to the camera viewpoint (Render::FrameCurrent->viewer.viewpoint, pc:432935-432962),
sets the portal's seen/inflag state in portal_view, and chooses the relevant portal_side. This is the
per-portal visibility/side determination.
PView::AddViewToPortals @ 0x5A52D0 (pc:433446, VERIFIED): walks the cell's portals; for each portal whose
other_cell exists and is flagged visible, it InitCells the neighbor and InsCellTodoLists it
(pc:433480-433485) — enqueuing the neighbor into the BFS — and SetOtherSeen (pc:433490). This is the
recursive portal traversal: visibility flows cell→neighbor only through portals the camera can see through.
Output: cell_draw_list[0..cell_draw_num] = the ordered list of visible CEnvCells, each with a
per-portal clip region stored in its portal_view (CEnvCell.num_view / portal_view,
acclient.h:32089-32090), plus outside_view = the accumulated exterior-portal clip region(s).
acdream parallel (already present): CellVisibility.GetVisibleCellsFromRoot (CellVisibility.cs:539)
is the same portal BFS — a Queue<LoadedCell>, per-portal InsideSide/clip-plane test (CellVisibility.cs:577-589,
"Source: ACME EnvCellManager.cs lines 1458-1459"), exit-portal detection (portal.OtherCellId == 0xFFFF → HasExitPortalVisible = true, CellVisibility.cs:561-565). So acdream's render already mirrors retail's
ConstructView; what's missing is consuming the result correctly + drawing the outside through the portal
(C10) and rooting it at the physics cell (C13/D).
C10. Drawing the OUTSIDE through a doorway/window (no blue clear-color hole)
This is the crux. Two pieces:
(1) Exit portals contribute a clip region to outside_view. Inside PView::ClipPortals @ 0x5A5520
(pc:433572, VERIFIED), when iterating a cell's portals, the branch at pc:433662-433685 handles a portal whose
other_cell id is 0xFFFFFFFF (an exterior portal):
if (*esi_3 == 0xFFFFFFFF) { // EXTERIOR portal
if (this->draw_landscape != 0) { // PView built with draw_landscape=true
if (cliplandscape != 0) Render::copy_view(this/*->outside_view*/, &clip_view, ecx_8);
else if (draw_landscape) Render::copy_view(this/*->outside_view*/, null, 0);
}
}
i.e., the exterior portal's screen clip region (clip_view, computed by GetClip) is copied into the
PView's outside_view. The draw_landscape flag is set at PView construction (PView::PView @ 0x5A5270
pc:433441: this->draw_landscape = arg2;, VERIFIED) — the indoor PView is built with
draw_landscape = true so doorways always feed the landscape view.
(2) DrawCells renders the landscape clipped to that region. PView::DrawCells @ 0x5A4840
(pc:432709, VERIFIED) opens with:
if (this->outside_view.view_count > 0) { // an exit portal was visible
Render::useSunlightSet(1);
Render::PortalList = this; // tell LScape to clip to outside_view
LScape::draw(this->lscape); // *** draw terrain + sky + exterior, clipped ***
D3DPolyRender::FlushAlphaList(0);
...
if (forceClear || portalsDrawnCount==0) // clear-color ONLY if nothing was drawn
RenderDevice::Clear(4, 0x820fc0, ...); // (pc:432731-432732)
... draw interior cells' surfaces (drawing_bsp), then portals ...
}
So the outdoors (terrain, sky, rain, exterior buildings) is drawn by LScape::draw @ 0x506330 (pc, VERIFIED
address) with Render::PortalList set to the PView, which clips it to the union of exit-portal screen
regions. The result: through a cottage doorway you see the actual world (sky/rain), not a clear-color hole.
The blue clear-color only appears if portalsDrawnCount == 0 — i.e., if the portal machinery produced
nothing (a truly sealed cell, or a bug).
Positioning the outside correctly: before DrawInside, RenderNormalMode (pc:92667-92670, VERIFIED)
does if (ebx_1 /*seen_outside*/) { eax_1 = Position::get_outside_cell_id(&viewer); LScape::update_viewpoint(lscape, eax_1); }. Position::get_outside_cell_id @ 0x4527B0 (pc:91552, VERIFIED)
converts the indoor camera position to the outdoor landcell id via LandDefs::adjust_to_outside. So the
landscape is centered on the landblock the building sits in, ready to be drawn through the doorway.
PView::GetClip @ 0x5A4320 (pc:432344, VERIFIED) is the clip-region builder: it projects the portal poly's
vertices to screen (PrimD3DRender::xformStart) and runs ACRender::polyClipFinish to produce the 2D clip
polygon, honoring Sidedness (front/back of the portal).
The exterior→interior recursion (camera OUTSIDE looking into a building): PView::ConstructView(CBldPortal, CPolygon portal, …) @ 0x5A59A0 (pc:433827, VERIFIED) is the mirror image — reached via PView::DrawPortal @ 0x5A5AB0 (pc:433895) while drawing the landscape. It side-tests the building portal poly against the camera,
GetClips it, and if the interior is visible recurses ConstructView(this, other_cell, other_portal_id)
(pc:433879) to draw the building's interior cells through the open door, clipped to the door's screen region.
So both directions are the same portal mechanism: outside↔inside is seamless because it's literally one
recursive portal-clipped traversal across the shared cell graph.
C11. Sealing interiors (ceilings capped, no bleed, entities clipped)
- Walls/ceilings are capped because each visible cell draws its own closed geometry.
DrawCells(pc:432745-432802, VERIFIED) draws eachcell_draw_listcell's surfaces usingcell->structure->drawing_bspandRender::SetSurfaceArray(cell->surfaces), per portal-view (CEnvCell::setup_viewperview_count). An EnvCell's geometry is a closed box (floor, 4 walls, ceiling) authored in the dat; thedrawing_bsporders/back-face-culls it. There is no "open top" — the ceiling polygon is part of the cell's surface array. So standing inside, the ceiling is present by construction. - No outdoor bleed-in because the outdoor world is only drawn through exit-portal clip regions
(
outside_view), never full-screen, when the camera cell is interior. The interior cells are drawn after / composited with the clipped landscape. TheClear(4,…)(depth/region clear) only fires where nothing was drawn. - Entities/particles clipped to visible cells: the final loop of
DrawCells(pc:432868-432882, VERIFIED) iteratescell_draw_listand for each callsDrawObjCellForDummies(cell)withRender::PortalListset to that cell's portal view — i.e., objects are drawn per-cell, clipped to that cell's visible portal region. An object in a non-visible cell is never incell_draw_list, so it isn't drawn; an object straddling a portal is clipped to the portal opening. (Object→cell membership comes from the physicsenter_cell/leave_cellshadow lists — the same cell graph; see C13.)
C12. Terrain + sky vs not, as a function of current cell
The decision tree (SmartBox::RenderNormalMode @ 0x453AA0, pc:92635-92684, VERIFIED), per frame:
viewer_cell = SmartBox::update_viewer's result (see C13)
outdoor_view = (viewer_cell is a LandCell / id < 0x100, OR static_camera special-case) // "edi_2"
ebx_1 = outdoor_view || viewer_cell->seen_outside
if (outdoor_view) { // camera is OUTSIDE
LScape::update_viewpoint(lscape, viewer.objcell_id);
Render::update_viewpoint(&viewer);
Render::set_default_view();
Render::useSunlightSet(1);
LScape::draw(lscape); // FULL terrain + sky (+ recurse into buildings)
} else { // camera is INSIDE an EnvCell
if (ebx_1 /*seen_outside*/) // interior that can see out:
LScape::update_viewpoint(lscape, Position::get_outside_cell_id(&viewer)); // pre-position terrain
Render::update_viewpoint(&viewer);
RenderDevice::DrawInside(viewer_cell); // PView portal traversal; terrain only through exits
}
So:
- Outdoor cell (
< 0x100): full landscape + sky drawn unconditionally (LScape::draw). Buildings are recursed into viaCBldPortalportals during the landscape draw. - Interior cell with
seen_outside(cottage/inn):DrawInside(interior cells), and the landscape is drawn only through visible exit portals (C10). Sky/rain appears in the doorway, not full-screen. - Interior cell without
seen_outside(dungeon):DrawInside,outside_view.view_countstays 0,LScape::drawis never reached, so no terrain, no sky — exactly what a dungeon needs.
RenderDeviceD3D::DrawInside @ 0x59F0D0 (pc:427843, VERIFIED) just forwards:
PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2).
C13. Is render's cell the SAME as physics's curr_cell? — YES (this is the central finding)
VERIFIED, conclusively. The render's camera cell is produced by SmartBox::update_viewer @ 0x453CE0
(pc:92761), which:
- Starts from
player->cell(the physics-committed cell, pc:92836/92842 —cell = this->player->cell). - Builds a camera transition (
CTransition::makeTransition+init_object(player, 0x5c)+init_sphere(1, &viewer_sphere, 1)+init_path(cell_1, desired_cam_pos, …), pc:92860-92866). This is the camera spring-arm / collision sweep — the camera is swept from the player toward the desired chase position and stopped on geometry (theSmartBox::update_viewerspring arm acdream already ported). - On
find_valid_positionsuccess:SmartBox::set_viewer(this, &eax_8->sphere_path.curr_pos, 0); this->viewer_cell = eax_8->sphere_path.curr_cell;(pc:92870-92871). The render's camera cell is thecurr_celltracked through that transition — the exact samevalidate_transitionmechanism physics uses for the player. - Fallbacks: if the camera transition fails,
AdjustPosition(&var_120, &viewer_sphere, &var_170, …)(pc:92878) resolves the cell statically and usesvar_170(pc:92881); last resortviewer_cell = null(pc:92887).
So render does not maintain an independent cell graph. It traverses the same CEnvCell/CCellPortal
graph that physics uses, and it derives the camera cell from a transition's sphere_path.curr_cell — exactly
like the player. The object→cell associations that clip entities (C11) come from the physics
enter_cell/leave_cell shadow lists. One graph, one membership concept, two consumers (player movement and
camera).
Contrast with acdream: acdream's render runs CellVisibility.FindCameraCell(cameraPos)
(CellVisibility.cs:389, "Ported from ACME EnvCellManager.cs FindCameraCell()") — an independent static
camera-cell resolver — and a separate VisibilityResult. The W2 work added ComputeVisibilityFromRoot that
can take the physics CurrCell as root (CellVisibility.cs:534), which is the right direction, but the
default path still resolves the camera cell on its own. This is precisely the "render maintains its own
cell/visibility system separate from physics" divergence the brief calls out.
D. Synthesis for acdream
D14. The retail-faithful target architecture
One cell-membership value, carried through the sweep, shared by physics and render.
-
Physics membership = the swept
curr_cell. Stop re-deriving the cell from the static origin. The cell the player is in is whatever the per-tick transition'ssphere_path.curr_cell(acdream:SpherePath.CurCellId) ended at — committed via achange_cell-style "only on difference" setter.validate_transitionalready advances it on OK+moved and reverts it on block/standstill; that is the only place membership should change. -
do_not_load_cellsprune for set-position paths. Port the prune intofind_cell_list, gated on aSetPositionStruct.flags & 0x20analogue, so authoritative/teleport sets cannot drift the cell. (For free movement the prune is not needed — accept-on-move + the directional picker suffice. Do not keep the ad-hocDoorwayHoldMarginhysteresis; it is a symptom-masking workaround of the static re-derive and is forbidden by the project's no-workarounds rule once the real mechanism lands.) -
Render obeys the physics cell + one portal-visibility traversal. The render's root cell should be the physics
CurrCell(the camera-cell case is a second transition that tracks its owncurr_cell, but both come from the shared graph and the sharedvalidate_transition).ComputeVisibilityFromRootalready exists; make it the default and feed it the physics answer. Draw the outside through exit portals (HasExitPortalVisible→ clip the landscape to the portal region), instead of leaving a clear-color hole.
D15. Specifically: should membership advance inside the sweep, and should render obey it?
Yes to both, and the decomp is unambiguous:
-
Advance membership inside the sweep (drop the static re-derive). Retail's
SetPositionInternal(CTransition)readsarg2->sphere_path.curr_celland commits viachange_cell-on-difference (pc:283403-283456). acdream must do the same:ResolveWithTransitionshould returnsp.CurCellId(the swept, accept-on-move cell), notResolveCellId(sp.GlobalSphere[0].Origin, …). The static re-derive (PhysicsEngine.cs:909/928) is the flicker source — it independently re-evaluates the boundary against a ±8 cm jittering origin every tick. BecauseValidateTransitionalready setssp.CurCellId = sp.CheckCellId(TransitionTypes.cs:3408) and the indoor cell-array picker already retargetssp.CheckCellIdto the containing cell mid-sweep (TransitionTypes.cs:2074-2075), the swept answer is already computed and stable — it's simply discarded. This is the single highest-leverage change and it is small.Critical caution: this touches the collision sweep, where acdream has a long bug history (the #98 saga, ~10 failed fixes). The change itself does not modify collision response — it changes only which already- computed cell id is returned. Keep the collision math byte-for-byte; change only the return value and the consumer in
PlayerMovementController/GpuWorldStatethat readsResolveResult.CellId. -
Render obeys the physics
curr_cell+ single portal traversal. Retail derivesviewer_cellfrom a transition'scurr_cell(pc:92871) and builds the visible set with oneConstructViewBFS (pc:433750). acdream should rootCellVisibilityat the physicsCurrCellanswer (W2'sComputeVisibilityFromRoot) rather than a separateFindCameraCell, and render the outside through exit portals. Justification: a separate render cell system is exactly what produces the threshold strobe (render cell and physics cell disagree by a frame) and the doorway clear-color hole (render never wires the exit portal to the landscape).
D16. Must-port functions, integration order, risks, conformance tests
Must-port / must-align functions (with retail addresses)
Physics — membership (the core):
| Retail fn | Addr | pc:LINE | acdream status |
|---|---|---|---|
CTransition::validate_transition |
0x50AA70 |
272547 | Present (TransitionTypes.cs:3398), advances CurCellId ✔ |
CPhysicsObj::SetPositionInternal(CTransition) |
0x515330 |
283399 | Missing the read — ResolveWithTransition discards sp.CurCellId |
CPhysicsObj::change_cell |
0x513390 |
281192 | Conceptually present (cell-id assignment); ensure "only on change" |
CObjCell::find_cell_list (master) |
0x52B4E0 |
308742 | Partial (CellTransit.FindCellList); needs the do_not_load_cells prune + directional picker semantics |
CObjCell::find_cell_list (sweep fwd) |
0x52B960 |
309085 | via CellTransit.FindCellSet |
CTransition::check_other_cells |
0x50AE50 |
272717 | Present (TransitionTypes.cs CheckOtherCells), retargets CheckCellId ✔ |
CEnvCell::find_transit_cells |
0x52C820 |
309968 | Present (portal expansion + outside add) |
CEnvCell::point_in_cell |
0x52C300 |
309677 | Present (CellBSP point test) |
CEnvCell::check_building_transit |
0x52C5D0 |
309827 | Present (CellTransit.CheckBuildingTransit) |
CLandCell::add_all_outside_cells |
0x533630 |
317499 | Present (AddAllOutsideCells) — verify added_outside once-guard |
CPhysicsObj::AdjustPosition |
0x511D80 |
280009 | Use for initial cell resolution only (teleport/login), not per-tick |
CEnvCell::find_visible_child_cell |
0x52DC50 |
311397 | Present (CellVisibility/ACE port) — for initial resolve + exit detection |
Render — seamless seal:
| Retail fn | Addr | pc:LINE | acdream status |
|---|---|---|---|
SmartBox::RenderNormalMode |
0x453AA0 |
92635 | The indoor/outdoor decision tree to mirror |
SmartBox::update_viewer |
0x453CE0 |
92761 | Spring-arm ported; also set render root = transition curr_cell |
PView::DrawInside |
0x5A5860 |
433793 | acdream GetVisibleCellsFromRoot is the BFS analogue |
PView::ConstructView(CEnvCell) |
0x5A57B0 |
433750 | Portal BFS ✔ (mirror exists) |
PView::ConstructView(CBldPortal) |
0x5A59A0 |
433827 | Exterior→interior recursion (outside-looking-in) — not yet |
PView::ClipPortals |
0x5A5520 |
433572 | Exit-portal→outside_view copy is the missing seam |
PView::DrawCells |
0x5A4840 |
432709 | outside_view>0 ⇒ LScape::draw clipped + per-cell object clip |
PView::GetClip |
0x5A4320 |
432344 | Portal screen-clip builder |
LScape::update_viewpoint / Position::get_outside_cell_id |
0x5062D0 / 0x4527B0 |
— / 91552 | Pre-position terrain for doorway draw |
Integration order (lowest-risk first)
- Membership return fix (physics). Change
ResolveWithTransitionto returnsp.CurCellId(the swept cell) instead ofResolveCellId(origin,…). Delete theDoorwayHoldMargin/sphere-overlap hysteresis inResolveCellIdonly after this lands clean (it becomes dead). Add thechange_cell-on-difference setter semantics so the W2CellGraph.CurrCellwriter fires only on actual change. Verify the flicker is gone withACDREAM_PROBE_CELL(one[cell-transit]per real cell change — should be ~1 at the doorway, not 20+/sec). This is the keystone; do it alone, verify, commit. do_not_load_cellsprune (physics, set-position only). Add the flag to the cell-array build, set it on authoritative/teleport set-position, port the prune loop fromfind_cell_list(pc:308829-308867 / ACEObjCell.cs:387-413). Confirms constrained sets don't drift the cell. Conformance test below.- Render root = physics cell. Make
CellVisibilitydefault toComputeVisibilityFromRoot(physics CurrCell)(camera-cell variant for 3rd person tracks its own viewer transition, but rooted in the same graph). Remove the independentFindCameraCelldefault once verified. Kills the threshold strobe. - Draw the outside through exit portals (render). When
HasExitPortalVisible, clip the landscape draw to the exit-portal screen region (GetClipanalogue) and draw terrain+sky there, pre-positioned viaget_outside_cell_id/update_viewpoint. Removes the blue clear-color hole; caps the dungeon (no exit portal ⇒ no landscape). MirrorPView::DrawCells'soutside_view>0 ⇒ LScape::drawgate. - (Optional/last) exterior→interior recursion (
ConstructView(CBldPortal)) for "outside looking into a building," if not already covered by the landscape→building portal path.
Main risks
- Touching the collision sweep. The membership-return fix is adjacent to the sweep but changes no
collision math — keep it that way. Do not "improve"
find_cell_listorcheck_other_cellswhile in there. The #98 saga proves speculative sweep edits regress. Land step 1 in isolation, verify, commit before step 2. change_cell-on-difference must be exact. If acdream commits the cell unconditionally (even when equal) it could re-fireenter_cell/leave_cellside-effects (shadow-list churn) every tick — verify the setter early-returns onthis->cell == newCell(retail pc:283414).- The directional picker must prefer interior cells. If acdream's
FindCellListreturns the first containing cell regardless of type (instead of "first interior containing cell wins, break"), the threshold can still pick outdoors. Match pc:308814-308819 / ACEObjCell.cs:378-382exactly. - Render root timing. The render must read the current frame's committed physics cell (after the physics
tick), not a stale one, or the strobe just moves. Order: physics tick → commit
CurrCell→ camera viewer transition → render BFS. - Dungeon vs cottage must both work from one path. The same code must seal a dungeon (no exit portal ⇒ no terrain) and a cottage (exit portal ⇒ terrain through doorway). Test both.
Conformance tests that would prove faithfulness
- Standing-still cell stability (the flicker test). Place the player at the cottage threshold
(the
0xA9B40170 ↔ 0xA9B40031spot), run N≥120 physics ticks with zero input. AssertCurrCellchanges 0 times after the initial settle (retail:validate_transition's no-move branch never promotes). This is the direct regression guard for the bug. - Doorway crossing is monotone. Walk slowly outdoor→vestibule→room and back. Assert the
[cell-transit]sequence is a clean monotone chain (0031 → 0170 → 0157 …then reverse) with exactly one transition per real boundary crossing — no oscillation, no skipped cells. validate_transitionaccept/revert unit test. DriveValidateTransitionwith (a) OK+moved → assertCurCellId == CheckCellId; (b) OK+not-moved → assertCurCellIdunchanged; (c) Collided/Slid → assertCurCellIdunchanged andCheckPos == CurPos. Mirrors pc:272593/272600/272612 and ACETransition.cs.find_cell_listdirectional picker + prune. Synthetic cell set where the foot sphere overlaps both an interior cell and the outdoor landcell: assert the picked containing cell is the interior one. Withdo_not_load_cellsset and a stranger cell present (not current, not in stab list): assert it's removed from the array; current cell and stab-list cells retained. (Port from ACEObjCell.csgolden behavior.)- Building entry/exit. From outdoors, walk into a cottage door: assert
CurrCelladvances to the interior EnvCell when the foot sphere crosses theCBldPortal(viaCheckBuildingTransit). From inside, walk out: assertCurrCellreturns to the outdoor landcell via theseen_outside/adjust_to_outsideexit. - Render seal (visual + assertion). Standing in the cottage facing the open door: assert the visible-set
build reports
HasExitPortalVisible == trueand that the landscape is drawn (no clear-color region in the doorway). Standing in a sealed dungeon cell: assertHasExitPortalVisible == falseand no terrain/sky draw call. (The first is the "see rain through the door" target; the second is the "dungeon has no sky" target.) - Render cell == physics cell. After a physics tick, assert
CellVisibilityroot cell id == player's committedCurrCellid (no independent re-resolve divergence).
Appendix: struct field anchors (verbatim from acclient.h, VERIFIED)
SPHEREPATH—acclient.h:32625-32671(curr_cell:32641, check_cell:32647, hits_interior_cell:32655, cell_array_valid:32666, num_sphere:32627, global_curr_center:32635).CELLARRAY—acclient.h:31574-31580(added_outside, do_not_load_cells, num_cells, cells).CELLINFO—acclient.h:31925-31929(cell_id, cell).CObjCell—acclient.h:30915-30932(pos, num_objects/object_list, num_shadow_objects/shadow_object_list, restriction_obj, num_stabs/stab_list:30927-30928, seen_outside:30929, myLandBlock_).CSortCell : CObjCell—acclient.h:31880-31883(building).CLandCell : CSortCell—acclient.h:31886-31890(polygons, in_view).CEnvCell : CObjCell—acclient.h:32072-32091(structure, num_portals/portals, num_static_objects/ static_objects, light_array, num_view/portal_view:32089-32090).CCellStruct—acclient.h:32275-32290(portals(CPolygon**), polygons, drawing_bsp/physics_bsp/cell_bsp).CCellPortal—acclient.h:32300-32308(other_cell_id, other_cell_ptr, portal, portal_side, other_portal_id, exact_match).CBldPortal—acclient.h:32094-32103(portal_side, other_cell_id, other_portal_id, exact_match, num_stabs/stab_list, sidedness).CBuildingObj : CPhysicsObj—acclient.h:31908-31916(num_portals/portals, num_leaves/leaf_cells, shadow_list).CLandBlockInfo—acclient.h:31893-31905(num_objects/object_ids/object_frames, num_buildings/buildings, restriction_table, cell_ownership, num_cells/cell_ids/cells).
Appendix: address index (all VERIFIED in symbols.json + pseudo-C)
Physics: change_cell 0x513390 · SetPositionInternal(CTransition) 0x515330 ·
SetPositionInternal(Position,SetPositionStruct,CTransition) 0x515BD0 · validate_transition 0x50AA70 ·
validate_placement_transition 0x50ADC0 · check_collisions 0x50AA00 · check_other_cells 0x50AE50 ·
transitional_insert 0x50B6F0 · find_transitional_position 0x50BDF0 · find_valid_position 0x50C310 ·
init_path(SPHEREPATH) 0x50CE20 · find_cell_list(master) 0x52B4E0 · find_cell_list(sweep fwd) 0x52B960 ·
CObjCell::GetVisible 0x52AD40 · CEnvCell::GetVisible 0x52DC10 · CLandCell::GetVisible 0x52DB0(→get_landcell) ·
CEnvCell::find_transit_cells 0x52C820 · CLandCell::find_transit_cells 0x533800 ·
CSortCell::find_transit_cells 0x534060 · CEnvCell::point_in_cell 0x52C300 · CLandCell::point_in_cell 0x532D40 ·
CEnvCell::check_building_transit 0x52C5D0 · CLandCell::add_all_outside_cells 0x533630 ·
CLandCell::find_collisions 0x532D60 · CBuildingObj::find_building_transit_cells 0x6B5230 ·
CBuildingObj::find_building_collisions 0x6B5300 · CCellPortal::GetOtherCell 0x53BA30 ·
CBldPortal::GetOtherCell 0x53BC30 · AdjustPosition 0x511D80 · CheckPositionInternal 0x511E90 ·
find_visible_child_cell 0x52DC50.
Render: SmartBox::RenderNormalMode 0x453AA0 · SmartBox::update_viewer 0x453CE0 · RenderDeviceD3D::DrawInside
0x59F0D0 · PView::DrawInside 0x5A5860 · PView::ConstructView(CEnvCell) 0x5A57B0 ·
PView::ConstructView(CBldPortal) 0x5A59A0 · PView::InitCell 0x5A4B70 · PView::ClipPortals 0x5A5520 ·
PView::AddViewToPortals 0x5A52D0 · PView::DrawCells 0x5A4840 · PView::GetClip 0x5A4320 ·
PView::AddToCell 0x5A4D90 · PView::OtherPortalClip 0x5A5400 · LScape::draw 0x506330 ·
LScape::update_viewpoint 0x5062D0 · Position::get_outside_cell_id 0x4527B0.