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>
70 KiB
Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering
Independent decomp study (model: Opus 4.8, variant A). 2026-06-02. Primary oracle: the named retail decomp at
docs/research/named-retail/(acclient_2013_pseudo_c.txt,acclient.h,symbols.json). Cross-checked againstreferences/ACE(C# physics port),references/ACViewer(MonoGame viewer),references/WorldBuilder. Citations arefunction @ 0xADDR (pseudo_c:LINE)for decomp andrepo/path:LINEfor references. Each non-trivial claim is tagged [VERIFIED] (read in source) or [INFERRED].
TL;DR (headline findings)
-
Retail never re-derives the cell from the final XYZ. It tracks the current cell (
sphere_path.curr_cell) through the collision sweep. The advance happens inCTransition::validate_transitionon an accepted sub-step (curr_cell = check_cell), and a blocked/standing-still sub-step explicitly resetscheck_cellback tocurr_cell— so a push-back can never change which cell you are in. At the end of the tick,CPhysicsObj::SetPositionInternal(CTransition*)readstransition.sphere_path.curr_celland callschange_cellonly when it differs from the held cell. This is the structural cure for the flicker. -
find_cell_listbuilds a candidate cell array (containment + portal neighbours), picks the single containing cell — interior cells win and short-circuit — and then applies thedo_not_load_cellsprune which removes every candidate that is neither the current cell nor in the current interior cell's static visibility list (stab_list). The prune is what buys stability for interior membership: it stops far/irrelevant cells from ever becoming the answer. -
Underground/dungeons are NOT a special mode. There is no "underground" flag in the retail client physics or render path. A dungeon is simply a landblock whose terrain heights are all zero, which has ≥1 EnvCell and no buildings — so you are always inside an
EnvCellwhoseseen_outside == 0, and the renderer therefore never draws terrain or sky. "Inside a building" differs only in that the EnvCell sits on a real terrain landblock and (often) hasseen_outside == 1, so the landscape is still drawn through the door. -
Retail renders inside+outside in ONE portal-visibility traversal (
PView).SmartBox::RenderNormalModemakes a single decision per frame fromviewer_cell(an outdoor landcell → draw the landscape; an EnvCell →DrawInside).PView::ConstructViewdoes a breadth-first portal walk that produces an ordered visible cell list (cell_draw_list) plus per-portal screen clip regions. When a portal'sother_cell_id == 0xffffffff(an exit portal to the outdoors) it pulls the landscape into the same traversal, clipped to the doorway, so there is no blue clear-color hole; sky/rain/ terrain show through. The depth clear is conditional, not an unconditional blue fill. -
Render and physics use the SAME cell graph and the SAME
objcell_id. The render-sideCellManager::ChangePositionresolves itscurr_cellviaCObjCell::Get(position.objcell_id)— the very same id physics maintains.seen_outsideis a per-cell dat flag (EnvCellFlags.SeenOutside = 0x1). Both the physics cell array and the render PVS traverse cells via the sameCEnvCell::portals/stab_list/GetVisiblemachinery (just different BSP trees inside the sameCCellStruct:physics_bsp/cell_bspvsdrawing_bsp). -
acdream's bug is localized and the fix surface already exists. acdream's
SpherePathalready tracksCurCellId/CheckCellIdand itsValidateTransitionalready advances/resets them correctly (TransitionTypes.cs:3398-3434). The only defect is at the engine output:ResolveWithTransition(PhysicsEngine.cs:909and:928) throws awaysp.CurCellIdand re-derives the cell statically viaResolveCellId(sp.GlobalSphere[0].Origin, …). Plus there is nodo_not_load_cellsprune inCellTransit, and the render maintains a separate cell system (CellVisibility.FindCameraCellwith a 3-frame grace-frame band-aid). The unified W1CellGraph(CurrCell+GetVisible) is the seam to land all three fixes on.
A. Cell membership & transitions (physics)
A0. The data model: SPHEREPATH and CObjCell
The single source of truth for "the cell I'm in" during a move is
SPHEREPATH::curr_cell. The struct (verbatim retail header) is:
acclient.h:32625 — struct SPHEREPATH [VERIFIED]:
struct SPHEREPATH {
...
CObjCell *begin_cell; // where the sweep starts (== object's current cell)
Position *begin_pos;
Position *end_pos; // requested destination
CObjCell *curr_cell; // ACCEPTED cell — the committed membership answer
Position curr_pos; // ACCEPTED position
...
CObjCell *check_cell; // CANDIDATE cell being tested this sub-step
Position check_pos; // CANDIDATE position
SPHEREPATH::InsertType insert_type; // TRANSITION_INSERT / PLACEMENT_INSERT
...
CObjCell *backup_cell; // saved for restore (step-up/walkable probes)
Position backup_check_pos;
int hits_interior_cell; // set when an interior cell was added/contained
int cell_array_valid;
...
};
The cell objects themselves (acclient.h:30915 CObjCell, :32072
CEnvCell, :31886 CLandCell) [VERIFIED]:
struct CObjCell : SerializeUsingPackDBObj, CPartCell {
LandDefs::WaterType water_type;
Position pos; // cell-to-world frame
...
unsigned int num_shadow_objects;
DArray<CShadowObj *> shadow_object_list; // collision objects registered in this cell
unsigned int restriction_obj;
...
unsigned int num_stabs;
unsigned int *stab_list; // STATIC visibility set: cell ids this cell can see
int seen_outside; // boolean: this interior can see the outdoors
...
CLandBlock *myLandBlock_;
};
struct CEnvCell : CObjCell {
...
CCellStruct *structure; // geometry: vertices, polys, 3 BSP trees
unsigned int num_portals;
CCellPortal *portals; // portal graph edges
unsigned int num_static_objects;
...
unsigned int num_view;
DArray<portal_view_type *> portal_view; // RENDER per-portal clip state
};
struct CLandCell : CSortCell { // CSortCell carries `building` (CBuildingObj*)
CPolygon **polygons;
BoundingType in_view;
};
Key takeaways for membership:
curr_cell≠ "the cell array".curr_cellis one cell (membership).cell_arrayis the multi-cell set the collision sweep queries (see A5).stab_listis a precomputed per-cell visibility list (the "stabs"). It is used by both the physics prune (A2) and the render PVS (C9).seen_outsideis a per-cell boolean meaning "this interior cell has an exterior portal / can see outdoors." (Confirmed as dat flagEnvCellFlags.SeenOutside = 0x1,references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7). [VERIFIED]
A1. The full membership chain (per tick)
The per-tick movement flows:
UpdateObjectInternal → transition → find_valid_position → find_transitional_position → (loop) transitional_insert + validate_transition → SetPositionInternal → change_cell.
Step 1 — seed curr_cell from the object's current cell
CPhysicsObj::transition @ 0x00512dc0 (pseudo_c:280904) [VERIFIED] builds a
fresh CTransition, then at :280939:
CTransition::init_path(result, this->cell, arg2 /*m_position*/, arg3 /*newPos*/);
init_path → SPHEREPATH::init_path @ 0x0050ce20 (pseudo_c:274359) [VERIFIED]
seeds:
this->begin_cell = arg2; // == CPhysicsObj.cell
...
this->curr_pos.objcell_id = arg3->objcell_id; // begin pos
this->curr_cell = arg2; // curr_cell STARTS as the held cell (:274370)
this->insert_type = TRANSITION_INSERT;
So the sweep begins anchored to the cell you are already in.
Step 2 — the sub-step loop advances check_pos, then validates
CTransition::find_transitional_position @ 0x0050bdf0 (pseudo_c:273613)
[VERIFIED] is the driver. It computes a step count (calc_num_steps), then
per sub-step (for TRANSITION_INSERT, pseudo_c:273736-273755):
// advance the CANDIDATE position by the per-step offset
this->sphere_path.check_pos.frame.origin += global_offset; // :273739-273741
SPHEREPATH::cache_global_sphere(&sphere_path, &global_offset);
var_44 = CTransition::validate_transition(this,
CTransition::transitional_insert(this, 3), &var_40); // :273743
...
ebx += 1;
if (ebx >= var_48) goto done;
continue;
At the very end (when all sub-steps are consumed, pseudo_c:273648-273651):
SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell);
sphere_path.cell_array_valid = 1;
sphere_path.hits_interior_cell = 0;
CObjCell::find_cell_list(&this->cell_array, nullptr, &this->sphere_path); // rebuild final cell array
Critical: the final find_cell_list rebuilds the collision cell_array
around the final position, but the membership answer is already in
curr_cell (advanced by validate_transition during the loop). The final
find_cell_list is passed arg5 = nullptr (no *currCell out-param), so it
does NOT re-pick membership.
Step 3 — transitional_insert: one sub-step of collision + cell discovery
CTransition::transitional_insert @ 0x0050b6f0 (pseudo_c:273137) [VERIFIED]
is the inner stepper. Per iteration:
edi = CTransition::insert_into_cell(this, sphere_path.check_cell, eax_2); // collide in check_cell
switch (edi) {
case OK_TS:
edi = CTransition::check_other_cells(this, sphere_path.check_cell); // discover/cross cells
...
}
insert_into_cell @ 0x00509e70 (pseudo_c:271991) [VERIFIED] simply calls
arg2->vtable->find_collisions(this) (the cell's own collision query). On
OK_TS, check_other_cells runs.
Step 4 — check_other_cells: cross into the right cell mid-sweep
CTransition::check_other_cells @ 0x0050ae50 (pseudo_c:272717) [VERIFIED]:
sphere_path.cell_array_valid = 1;
sphere_path.hits_interior_cell = 0;
CObjCell::find_cell_list(&this->cell_array, &var_4c /*out: containing cell*/, &sphere_path);
for (i : cell_array) { // collide against every other cell
cell = cell_array.cells[i].cell;
if (cell && cell != arg2)
result = cell->vtable[+0x88](this); // find_collisions
if (result is COLLIDED/ADJUSTED/SLID/CP-clear) handle/return;
}
check_cell = var_4c; // RETARGET candidate cell to the containing cell
if (check_cell != 0) {
SPHEREPATH::adjust_check_pos(&sphere_path, check_cell->id); // rebase check_pos id to new cell
return result;
}
// --- no containing cell found: we have left the indoor graph ---
if (sphere_path.step_down) return COLLIDED_TS;
objcell_id = sphere_path.check_pos.objcell_id;
if (objcell_id < 0x100) { /* already outside */ }
else LandDefs::adjust_to_outside(objcell_id, &frame); // CONVERT interior id -> outdoor landcell id
if (objcell_id == 0) return COLLIDED_TS;
SPHEREPATH::adjust_check_pos(&sphere_path, objcell_id);
sphere_path.check_cell = nullptr; // will be resolved next find_cell_list
This is the indoor→outdoor exit: when the foot sphere no longer lands in
any portal-connected interior cell, adjust_to_outside
(LandDefs::adjust_to_outside @ 0x005a9bc0 (pseudo_c:438719) [VERIFIED])
maps the interior cell id to the surrounding landblock's outdoor cell id, and
check_cell becomes the landcell on the next pass.
Step 5 — validate_transition: ADVANCE on accept, RESET on block (the anti-flicker)
CTransition::validate_transition @ 0x0050aa70 (pseudo_c:272547) [VERIFIED].
The decisive control flow:
if (transitionState != OK || check_pos == curr_pos) // blocked OR no movement
{
if (transitionState != OK) {
if (transitionState != INVALID) { // a real block/slide/adjust
... (contact-plane / last-known-plane handling) ...
SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // :272593
// ^^^ RESET candidate back to curr_pos / curr_cell — cell does NOT change
CTransition::build_cell_array(this, nullptr);
transitionState = OK_TS;
}
} else {
// check_pos == curr_pos: accept-no-move => label_50aba9 advances (no-op)
}
}
else // OK and we moved
{
label_50aba9: // :272608
check_cell = sphere_path.check_cell;
sphere_path.curr_pos.objcell_id = sphere_path.check_pos.objcell_id;
sphere_path.curr_cell = check_cell; // :272612 ADVANCE membership
SPHEREPATH::cache_global_curr_center(&sphere_path);
// re-seed check_* = curr_* for the next sub-step
sphere_path.check_pos.objcell_id = sphere_path.curr_pos.objcell_id;
sphere_path.check_cell = sphere_path.curr_cell; // :272617
sphere_path.cell_array_valid = 0;
}
The anti-flicker guarantee, stated precisely:
- A sub-step that moves and is accepted (
OK_TS&&check_pos != curr_pos) advancescurr_cell = check_cell(the cell that the sweep crossed into). - A sub-step that is blocked/slid/adjusted (not
OK_TS) callsset_check_pos(curr_pos, curr_cell)— it rolls the candidate cell BACK to the already-accepted cell. Membership cannot change on a block. - A sub-step that is OK but does not move (
check_pos == curr_pos) takes the no-op path; membership stays.
So push-back jitter — which is exactly a "block" producing a small reversal —
never changes curr_cell. The cell only changes when the swept path
genuinely crosses into a new cell and that crossing is accepted.
ACE cross-check [VERIFIED] — references/ACE/Source/ACE.Server/Physics/Transition.cs:984
ValidateTransition:
if (transitionState != TransitionState.OK || SpherePath.CheckPos.Equals(SpherePath.CurPos)) {
...
SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); // line 1014 (block path: reset)
...
} else
SetCurrentCheckPos(); // line 1024 (accept path)
SetCurrentCheckPos() (Transition.cs:1084):
SpherePath.CurPos = new Position(SpherePath.CheckPos);
SpherePath.CurCell = SpherePath.CheckCell; // ADVANCE (line 1087)
SpherePath.CacheGlobalCurrCenter();
SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell);
ACE matches the decomp exactly.
Step 6 — commit: SetPositionInternal reads curr_cell, calls change_cell only on change
CPhysicsObj::UpdateObjectInternal @ 0x005156b0 (pseudo_c:283611) [VERIFIED]:
class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); // :283673
if (eax_10 == 0) { ... no move ... }
else {
Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); // velocity
...
CPhysicsObj::SetPositionInternal(this, eax_10); // :283696
}
CPhysicsObj::SetPositionInternal(CTransition*) @ 0x00515330 (pseudo_c:283399)
[VERIFIED] — the membership commit:
class CObjCell* curr_cell = arg2->sphere_path.curr_cell; // :283403 READ the swept cell
if (curr_cell == 0) { prepare_to_leave_visibility(); store_position(...); GotoLostCell(...); }
else {
if (this->cell == curr_cell) { // :283414 SAME cell
this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id; // just update id
// (also propagate id to part_array + children)
} else {
CPhysicsObj::change_cell(this, curr_cell); // :283456 CHANGE only on differ
}
CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame);
...
}
CPhysicsObj::change_cell @ 0x00513390 (pseudo_c:281192) [VERIFIED]:
if (this->cell != 0) CPhysicsObj::leave_cell(this, 1); // remove from old cell's shadow list
if (arg2 != 0) { CPhysicsObj::enter_cell(this, arg2); return; } // add to new cell's shadow list
this->m_position.objcell_id = 0; this->cell = nullptr;
ACE cross-check [VERIFIED] — PhysicsObj.cs:1171 SetPositionInternal(Transition):
var transitCell = transition.SpherePath.CurCell; // line 1174 READ swept cell
...
if (transitCell.Equals(CurCell)) { Position.ObjCellID = curPos.ObjCellID; ... } // line 1192
else change_cell(transitCell); // line 1210 CHANGE only on differ
This is the entire flicker story. Retail's membership is a latch: it is advanced through the sweep and committed at the end, never recomputed from the static endpoint. acdream re-derives it (see D).
A2. find_cell_list: building the candidate array, picking the containing cell, the prune
CObjCell::find_cell_list @ 0x0052b4e0 (pseudo_c:308742) [VERIFIED]. This is
the canonical 6-argument overload; the 3-arg form
(@ 0x0052b960 (pseudo_c:309085)) just forwards from a SPHEREPATH
(find_cell_list(&check_pos, num_sphere, global_sphere, cellarray, currcell, path)).
Annotated control flow:
void find_cell_list(Position* pos, uint num_sphere, CSphere* sphere,
CELLARRAY* arr, CObjCell** out_currCell, SPHEREPATH* path)
{
arr->num_cells = 0;
arr->added_outside = 0;
objcell_id = pos->objcell_id;
visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // :308754
: CLandCell::GetVisible(objcell_id); // :308756
// (1) SEED the array
if (objcell_id >= 0x100) { // interior
if (path) path->hits_interior_cell = 1;
CELLARRAY::add_cell(arr, objcell_id, visibleCell); // :308766
} else { // outdoor
CLandCell::add_all_outside_cells(pos, num_sphere, sphere, arr); // :308769
}
if (visibleCell && num_sphere != 0) {
// (2) EXPAND through portals: each cell adds its reachable neighbours
for (i : arr->num_cells)
arr->cells[i].cell->vtable[+0x80](pos, num_sphere, sphere, arr, path); // find_transit_cells :308782
// (3) PICK the single containing cell
if (out_currCell) {
*out_currCell = nullptr;
for (i : arr->num_cells) {
cell = arr->cells[i].cell;
blockOffset = LandDefs::get_block_offset(pos->objcell_id, cell->id); // :308802
localPoint = sphere->center - blockOffset;
if (cell->vtable[+0x84](&localPoint)) { // point_in_cell :308810
*out_currCell = cell;
if (cell->id >= 0x100) { // INTERIOR cell wins
if (path) path->hits_interior_cell = 1;
break; // :308819 short-circuit
}
}
}
}
}
// (4) do_not_load_cells PRUNE
if (arr->do_not_load_cells && (pos->objcell_id & 0xFFFF) >= 0x100) { // :308829
for (i : arr->num_cells) {
cell_id = arr->cells[i].cell_id;
if (cell_id == visibleCell->id) continue; // keep self (+0x28)
found = false;
for (s : visibleCell->stab_list) // (+0xe0 num, +0xe4 ptr)
if (cell_id == s) { found = true; break; }
if (!found) CELLARRAY::remove_cell(arr, i); // :308863 DROP non-visible cell
}
}
}
The four mechanisms, explained:
- Seed. Interior position seeds with its own cell; outdoor seeds with the
landcell neighbourhood (
add_all_outside_cells). - Expand.
find_transit_cells(vtable +0x80) adds cells reachable across the foot-sphere from this cell's portals — this is how a sweep that straddles a portal sees both cells. - Pick. The containing cell is the first whose
point_in_cellreturns true. An interior cell that contains the point wins andbreaks the loop (:308814-308819) — interior cells take priority over outdoor cells. (Without this, a foot sphere overlapping both the doorway landcell and the vestibule could pick either.) - Prune (
do_not_load_cells). When set and the position is interior, remove every candidate that is neither the current cell nor in the current cell'sstab_list. Thestab_listis the cell's static set of "cells I can see." Outdoor landcells are essentially never in an interior cell's stab list, so when the prune is active, an interior position cannot have the array poisoned by outdoor cells — which is the membership-stability guarantee. The prune is the "do not stream/consider cells my interior can't see" rule.
do_not_load_cells — when is it set? It is a per-CELLARRAY flag
(acclient.h:31577, int do_not_load_cells; in struct CELLARRAY). [VERIFIED]
It is set on the detection / collision cell arrays used for object-vs-object
queries and for the per-tick transition cell array where you want a stable
neighbourhood that doesn't pull in unloaded/invisible cells. ACE exposes it as
CellArray.LoadCells (inverted sense): when LoadCells == false the prune
runs. The prune's effect is the stability it buys (item 4 above). [INFERRED
from struct + ACE] that the per-tick player transition array is built with
the prune active for interior movers; the decomp's find_cell_list only runs
the prune branch when do_not_load_cells != 0, and it is unconditionally
correct to enable it for an interior position.
ACE cross-check [VERIFIED] — references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335
find_cell_list is a near-line-for-line match: seed (:342-350), expand
(:354-359), pick with interior short-circuit (:365-385, the
return; // break? at :381), prune (:387-412, gated on !cellArray.LoadCells
and (position.ObjCellID & 0xFFFF) >= 0x100, removing cells not in
((EnvCell)visibleCell).VisibleCells).
A3. Precisely how retail avoids cell flicker
It is the combination of A1 + A2, in this order of importance:
-
Swept-path containment with accept-on-move (A1, dominant). Membership is advanced only when the sweep crosses into a new cell and the crossing sub-step is accepted; a blocked/standing-still sub-step resets the candidate back to the held cell (
validate_transition,:272593reset vs:272612advance). The end-of-tick commit readscurr_celland onlychange_cells on a real difference (SetPositionInternal,:283414vs:283456). A ±8 cm push-back is a block, which by construction does not move membership. -
Interior-wins short-circuit in
find_cell_list(A2 item 3). When a sphere straddles a doorway and both an interior and an outdoor cell contain the point, the interior cell is picked and the loop breaks. This prevents the "should I be in the vestibule or the landcell?" oscillation at the exact threshold. -
do_not_load_cellsprune (A2 item 4). For an interior mover, the candidate array can never contain an outdoor cell (it's not in thestab_list), so the cross/containment machinery operates on a stable interior-only neighbourhood. -
point_in_cellsemantics (BSP, A4). Containment is a true BSP point-in-solid test in cell-local space, not an AABB — so the boundaries are the actual cell geometry, giving crisp, non-overlapping membership at walls.
What guarantees a blocked/standing-still step does NOT change the cell: the
set_check_pos(curr_pos, curr_cell) reset at validate_transition:272593 (block
path) and the check_pos == curr_pos no-op path at :272600-272606 (no move),
combined with SetPositionInternal's if (this->cell == curr_cell) guard at
:283414. [VERIFIED]
A4. Transitions: indoor→outdoor, outdoor→indoor, interior→interior; CCellPortal vs CBldPortal
Two distinct portal types exist:
CCellPortal(acclient.h:32300) [VERIFIED] — connects two interior EnvCells (room↔room). Fields:other_cell_id,other_cell_ptr(cached neighbour),portal(the polygon),portal_side,other_portal_id,exact_match. Another_cell_id == 0xffffffffmeans the portal opens to the outside (an exit portal).CBldPortal(acclient.h:32094) [VERIFIED] — connects the outdoor landblock to a building's interior (the door from the street). Fields:portal_side,other_cell_id,other_portal_id,exact_match,num_stabs,stab_list,sidedness. Held byCBuildingObj.portals(acclient.h:31908).
Interior→interior (room↔room)
CEnvCell::find_transit_cells (sphere variant) @ 0x0052c820 (pseudo_c:309968)
[VERIFIED]: for each CCellPortal, get the neighbour via
CCellPortal::GetOtherCell, transform the sphere to that cell's local space,
and if CCellStruct::sphere_intersects_cell != OUTSIDE, add_cell the
neighbour (:310054). The portal_side field gates direction. When a portal's
other_cell_id == 0xffffffff (exterior portal) the function sets var_44,
and after the loop:
if (var_44 != 0) CLandCell::add_all_outside_cells(arg2, arg3, arg4, arg5); // :310119-310120
So an interior cell with an exit portal also pulls in the outdoor landcell neighbourhood — making the outdoor landcell a candidate when you're near the door from inside.
Outdoor→indoor (street→building)
The landcell carries a building via CSortCell (the CLandCell : CSortCell
inheritance). CLandCell::find_transit_cells @ 0x00533800 (pseudo_c:317603)
[VERIFIED]:
CLandCell::add_all_outside_cells(...); // neighbour landcells
CSortCell::find_transit_cells(this, ...); // the building on this landcell
CSortCell::find_transit_cells @ 0x00534060 (pseudo_c:318309) [VERIFIED]
→ CBuildingObj::find_building_transit_cells @ 0x006b5230 (pseudo_c:701214)
[VERIFIED]:
for (i : building->num_portals) {
CBldPortal* p = building->portals[i];
CEnvCell* interior = CBldPortal::GetOtherCell(p);
if (interior) CEnvCell::check_building_transit(interior, p->other_portal_id, ...); // :701227
}
check_building_transit adds the interior EnvCell to the array if the sphere
has crossed the building portal plane. So when you walk a foot-sphere up to a
door from the street, the interior vestibule becomes a candidate, point_in_cell
picks it (interior wins), and validate_transition advances curr_cell into
the building.
Indoor→outdoor (exit)
As shown in A1 Step 4: when no interior cell contains the sphere center,
check_other_cells calls LandDefs::adjust_to_outside to convert the interior
cell id to the surrounding landcell id (pseudo_c:272783), then re-resolves.
Position::get_outside_cell_id @ 0x004527b0 (pseudo_c:91552) [VERIFIED] is the
helper that computes the outdoor landcell id from an interior position's frame
(used by render too).
How the exit portal / outdoor landcell gets added and chosen
- Added: via
add_all_outside_cells(triggered byvar_44infind_transit_cellswhen an exit portal is present, or directly in the outdoor seed branch). - Chosen: by
point_in_cellin thefind_cell_listpick loop — but the outdoor cell is only chosen once the sphere center is no longer inside any interior cell (interior wins, A2 item 3). So there is a clean handoff: you remain in the interior until the center leaves the interior solid, then the landcell wins.
A5. Two mechanisms or one? — the cell ARRAY vs curr_cell
They are two distinct things that interact within one transition. [VERIFIED]
-
CELLARRAY cell_array(acclient.h:31574) — a set of cells the collision sweep must test against this tick. Built byfind_cell_list/find_transit_cells. Used byinsert_into_cell(the primary cell) andcheck_other_cells(every other cell in the array). Its purpose is collision coverage: you must collide against walls/floors of every cell your sphere overlaps, not just one. -
sphere_path.curr_cell/check_cell— the single membership cell.curr_cell= accepted;check_cell= candidate this sub-step.
How they relate in one transition: Each sub-step, check_other_cells calls
find_cell_list(&cell_array, &out_containing, &sphere_path). This:
(a) rebuilds the cell_array (collision set) for the candidate position, and
(b) returns the single containing cell in out_containing, which becomes the
new check_cell (check_other_cells:272760-272765). Then validate_transition
promotes check_cell → curr_cell on accept. So the array drives collision;
the containing-cell pick drives membership; both come out of the same
find_cell_list call but are different outputs.
This distinction matters for acdream: acdream already iterates a candidate set
for collision (Phase A4 CheckOtherCells, TransitionTypes.cs:2055-2067), and
already retargets CheckCellId to the containing cell mid-sweep
(TransitionTypes.cs:2074-2075). The membership latch is therefore already
tracked in sp.CurCellId. (See D.)
B. Underground / dungeons
B6. How dungeons are represented (dat + runtime), vs building interiors
There is no separate dungeon data type. Both dungeons and building
interiors are made of the same CEnvCell graph (acclient.h:32072). The
difference is purely in the landblock they sit on:
- A landblock (
CLandBlockInfo,acclient.h:31893) [VERIFIED] carriesnum_cells+cell_ids+cells (CEnvCell**)— the interior cells — and separatelynum_buildings+buildings (BuildInfo**). - A dungeon landblock has terrain heights all zero, ≥1 EnvCell, and no buildings.
- A building interior (cottage/inn) sits on a landblock with real
terrain and the interior is reached through a
CBldPortalfrom the outdoors; the EnvCells frequently haveseen_outside == 1.
ACE's authoritative definition [VERIFIED] —
references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575 IsDungeon:
// a dungeon landblock is determined by:
// - all heights being 0
// - having at least 1 EnvCell (0x100+)
// - contains no buildings
foreach (var height in Height) if (height != 0) { isDungeon = false; return; }
isDungeon = Info != null && Info.NumCells > 0 && Info.Buildings != null && Info.Buildings.Count == 0;
And HasDungeon (:621) is the same minus the height check — for landblocks
with both an overworld and a basement (e.g. mansions).
Important caveat:
IsDungeon/HasDungeonare ACE server heuristics for spawn/teleport logic. The retail client does not compute or need them. The client just renders whatever cell the viewer is in (C12). I cite them only to characterize the dat layout difference, not as a client code path. [VERIFIED they are ACE-only; INFERRED the client has no equivalent — no such function found in the decomp.]
Cell id convention (the runtime discriminator) [VERIFIED via decomp use]:
- Low 16 bits
0x0001..0x0040(1–64) → an outdoor landcell of a landblock (8×8 grid).find_cell_listtreats(id & 0xFFFF) < 0x100as outdoor (pseudo_c:308761,308753). - Low 16 bits
>= 0x0100→ an EnvCell (interior, building or dungeon). - High 16 bits = the landblock id. So
0xA9B40031= landblock0xA9B4, outdoor cell0x31;0xA9B40170= landblock0xA9B4, EnvCell0x170.
B7. Moving through a dungeon: cell tracking, loading, no sky/terrain
- Cell tracking is identical to building interiors — the same
transition/validate_transition/change_cellmachinery, just that every cell you cross is an EnvCell (>= 0x100) connected byCCellPortals. - Loading/streaming. When the player's cell changes,
CEnvCell::grab_visible_cells @ 0x0052e220 (pseudo_c:311878)[VERIFIED] is called (viaCellManager::ChangePosition, see C13). It adds the current cell- every cell in its
stab_listto the visible-cell table:
This is the precise place the engine decides "load the outdoor world too" or not:CEnvCell::add_visible_cell(this->id); for (i : num_stabs) CEnvCell::add_visible_cell(stab_list[i]); if (this->seen_outside == 0) return; // :311893 — dungeon: STOP, no landscape return LScape::grab_visible_cells(landscape); // building: also load the terrainseen_outside == 0→ don't touch the landscape (dungeon). A building interior withseen_outside == 1loads the surrounding terrain. - every cell in its
- No sky/terrain to draw is decided by
seen_outsideat render time (C10/C12): if the viewer cell and all visible cells haveseen_outside == 0, no exit portal is ever encountered, soPView::DrawCells'outside_view.view_countstays 0 andLScape::drawis never called (pseudo_c:432715). [VERIFIED]
B8. Is there an explicit "underground" flag?
No explicit "underground" flag exists in the retail client. [VERIFIED — by absence]. "Underground" is emergent:
You are underground iff your current cell is an
EnvCell(id & 0xFFFF >= 0x100) whoseseen_outside == 0and which has no exit-portal path to the outdoors.
The closest thing to a flag is the per-cell seen_outside boolean
(CObjCell.seen_outside, acclient.h:30929; dat flag
EnvCellFlags.SeenOutside = 0x1, references/ACViewer/.../EnvCellFlags.cs:7).
[VERIFIED]. It is consumed at:
CEnvCell::grab_visible_cells:311893(load landscape or not),SmartBox::RenderNormalMode:92649andCellManager::ChangePosition:94575,94649,94682(draw landscape / keep landscape loaded).
There is no is_underground field on Position, on the landblock, or on
the cell. The distinction "dungeon vs building interior vs outdoor" is fully
captured by (cell id range) × (seen_outside) × (presence of exit portals).
C. Rendering inside and outside — the seamless seal
C9. The single-pass visible-set build (PView)
The render visibility is a breadth-first portal traversal rooted at the
viewer's cell, producing an ordered cell-draw list plus per-portal screen
clip regions. The data structures (acclient.h:32346) [VERIFIED]:
struct portal_view_type { // one per EnvCell (CEnvCell.portal_view)
DArray<portal_info> portal; // per-portal seen/inflag state
view_type view; // poly + vertex screen-clip geometry
float max_indist;
unsigned int view_count;
int cell_view_done;
int view_timestamp;
int update_count;
};
struct view_type { unsigned vertex_count_total; DArray<view_poly> poly; DArray<view_vertex> vertex; };
PView::ConstructView(CEnvCell* root, ushort) @ 0x005a57b0 (pseudo_c:433750) [VERIFIED]
this->outside_view.view_count = 0;
PView::master_timestamp += 1;
this->cell_todo_num = 0; this->cell_draw_num = 0;
PView::InitCell(this, root, arg3); // compute root's active portals + clip
PView::InsCellTodoList(this, root, 0); // push root onto BFS todo
while (true) {
if (cell_todo_num <= 0) return;
cell = cell_todo_list[--cell_todo_num].cell;
if (cell == 0) return;
cell_draw_list[cell_draw_num++] = cell; // ADD to visible draw list
cell->portal_view[...]->cell_view_done = 1;
if (PView::ClipPortals(this, cell, 0)) // clip this cell's portals to current view
PView::AddViewToPortals(this, cell); // enqueue neighbours through visible portals
}
PView::InitCell @ 0x005a4b70 (pseudo_c:432896) [VERIFIED]
For the cell, walks num_portals, computes each portal polygon's plane vs the
viewer (Render::FrameCurrent->viewer.viewpoint), and sets per-portal
inflag/seen based on portal_side (the side the viewer is on). Computes
max_indist (esi[0xd]). This decides which of the cell's portals can be seen
through from the current viewpoint.
PView::AddViewToPortals @ 0x005a52d0 (pseudo_c:433446) [VERIFIED]
For each active portal of the cell:
esi_2 = portal.other_cell_ptr; // neighbour EnvCell
if (esi_2 && portal_active) {
if (neighbour not yet inited) {
InitCell(this, esi_2, portal.other_portal_id);
InsCellTodoList(this, esi_2, max_indist); // enqueue neighbour
if (portal.flag >= 0) SetOtherSeen(this, cell, portal_idx); // record exit-portal seen
} else AddToCell(...); // merge additional view
}
This is the recursive portal expansion: each visible portal pulls its neighbour cell into the todo list, clipped to the portal's screen region.
Output
cell_draw_list[0..cell_draw_num]— the ordered set of visible EnvCells.- Per-cell
portal_view[...]->view— the accumulated screen-space clip region(s) (poly+vertex) through which each cell is visible (so a far cell seen through two doorways is clipped to the intersection). outside_view— accumulated clip region for the outdoors seen through exit portals (drivesLScape::draw, C10).
Cross-check: WorldBuilder and ACViewer do NOT implement this. ACViewer
brute-force draws all loaded EnvCells with a DungeonMode cull-mode toggle
(references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564) [VERIFIED] —
no portal-recursive clip. WorldBuilder uses a flat stencil "inside-out" pass
(per project memory project_indoor_portal_visibility.md). So PView is the
unique authority for the seamless seal; the references diverge here and the
decomp wins.
C10. Drawing the OUTSIDE through a doorway (no blue hole)
Two cooperating mechanisms.
(a) Exit portals carry the landscape into the indoor traversal
In PView::ClipPortals @ 0x005a5520 (pseudo_c:433572) [VERIFIED], when a
portal's other_cell_id == 0xffffffff (an exterior portal):
if (*(uint32_t*)esi_3 == 0xffffffff) { // :433662 exit portal
if (this->draw_landscape) { // :433664
if (cliplandscape) Render::copy_view(this, &clip_view, ecx_8); // :433674 — landscape clipped to portal
else if (draw_landscape) Render::copy_view(this, nullptr, 0);
}
}
So the doorway's screen region (clip_view) is registered as a region through
which the landscape is drawn. The this->draw_landscape flag is set when
the indoor PView is allowed to show outdoors.
(b) DrawCells draws the landscape first when any portal opened outside
PView::DrawCells @ 0x005a4840 (pseudo_c:432709) [VERIFIED]:
if (this->outside_view.view_count > 0) { // :432715 some portal saw outside
Render::useSunlightSet(1);
Render::PortalList = this;
LScape::draw(this->lscape); // :432719 DRAW THE OUTDOOR WORLD (terrain+sky+buildings)
D3DPolyRender::FlushAlphaList(0);
...
// CONDITIONAL clear — only clears Z where portals were drawn:
if (forceClear || D3DPolyRender::portalsDrawnCount != 0)
RenderDevice->Clear(4, 0x820fc0, 1.0); // :432731-432732 (4 == Z-only)
// then draw each visible EnvCell's drawing_bsp + portal polys:
for (cell : cell_draw_list) {
if (cell->structure->drawing_bsp) {
... setup_view per portal_view ...
for (portal : cell->portals)
if (portal.other_cell_id == 0xffffffff)
D3DPolyRender::DrawPortalPolyInternal(portal.portal, 0); // :432785-432786 stencil the opening
}
}
for (cell : cell_draw_list) RenderDevice->DrawEnvCell(cell); // :432853 draw interior geometry
}
Why there is no blue clear-color hole:
- The landscape (terrain + sky + exterior buildings) is drawn first, inside
the doorway clip region, when
outside_view.view_count > 0. - The
ClearisClear(4, …)— flag4is the Z-buffer, not the full color buffer; and it is conditional on portals having been drawn. There is no unconditionalClear(color)painting the frame blue. - The exit-portal polygons are drawn as stencil masks (
DrawPortalPolyInternal) so the outdoors only shows through the actual door opening.
So from inside a cottage, looking at the door, you see the real terrain/sky/ rain through the opening — clipped to the door — and the walls around it. The "blue hole" in acdream is precisely the absence of step (b): acdream's indoor pass clears to clear-color and never injects the landscape into the doorway clip.
PView::DrawInside @ 0x005a5860 (pseudo_c:433793) [VERIFIED] is the top-level
indoor entry that ties it together:
CEnvCell::curr_view_push(viewer_cell);
PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // seed PVS from stab list
Render::copy_view(viewer_cell->portal_view[...], nullptr, 4);
edx = PView::ConstructView(this, viewer_cell, 0xffff); // build visible set
PView::DrawCells(this, edx); // draw (incl. landscape thru doors)
PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list);
PView::ConstructView(CBldPortal*, CPolygon*, …) @ 0x005a59a0 (pseudo_c:433827)
[VERIFIED] is the building-portal sibling (used by DrawPortal/outdoor
pview): it tests viewer-vs-portal-plane sidedness, gets the interior via
CEnvCell::GetVisible(other_cell_id), clips, and recurses
ConstructView(interior, other_portal_id) (:433879). This is how the
outdoor render draws into a building when the camera can see the door
from the street — the mirror image of (a)/(b).
C11. Sealing interiors: capped ceilings, no bleed-through, entity/particle clipping
- Ceilings/walls are sealed by construction. An EnvCell's geometry
(
CCellStruct.polygons+drawing_bsp,acclient.h:32285) is a closed cell — the dat authoring includes the ceiling/floor/walls as polygons. There is no "cap the ceiling" step; the cell mesh is already a sealed box with portal holes only whereCCellPortals exist.DrawCellsdrawscell->structure->drawing_bspfor each visible cell (pseudo_c:432745,432853). [VERIFIED] - No outdoor bleed-in. Because the visible set is only the cells reached
by the portal BFS (
cell_draw_list), and the landscape is drawn only through exit-portal clip regions, the outdoor world cannot paint over interior walls. Ifseen_outside == 0everywhere reachable, the landscape is never drawn at all. - Entity/particle clipping to visible cells. Objects live in a cell's
shadow_object_list/object_list(CObjCell.object_list,acclient.h:30920).DrawCellsdraws per-cell objects viaDrawObjCellForDummies(cell)(pseudo_c:432878) walking only thecell_draw_list. An object in a non-visible cell is simply not iterated.Render::PortalListis set per cell so object draws inherit the same clip region (:432877). [VERIFIED] Particles attached to objects follow the object's cell membership.
C12. Terrain + sky: drawn or not, as a function of the current cell
The decision is made once per frame in
SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635) [VERIFIED]:
// edi_2 ≈ "viewer is in an outdoor landcell" (decompiler-garbled, but the branch is binary)
if (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ebx_1 = 1; // :92649 outside-relevant
else ebx_1 = 0;
if (edi_2 == 0) { // VIEWER IS INSIDE AN ENVCELL
if (ebx_1 != 0) { // cell seen_outside: update landscape viewpoint too
eax_1 = Position::get_outside_cell_id(&this_1->viewer);
LScape::update_viewpoint(this_1->lscape, eax_1);
}
Render::update_viewpoint(&this_1->viewer);
RenderDevice->DrawInside(viewer_cell); // :92675 indoor PView path (draws landscape thru doors if seen_outside)
} else { // VIEWER IS OUTSIDE (landcell)
LScape::update_viewpoint(this_1->lscape, this_1->viewer.objcell_id);
Render::update_viewpoint(&this_1->viewer);
Render::set_default_view();
Render::useSunlightSet(1);
LScape::draw(this_1->lscape); // :92683 normal outdoor render
}
So:
- Viewer in an outdoor landcell (
id & 0xFFFF < 0x100) →LScape::draw(full terrain + sky + buildings; buildings draw their interiors via building-portalConstructViewwhen the camera sees the door). - Viewer in an EnvCell →
DrawInside(portal PVS). Terrain/sky appear only through exit portals, and only if some reachable cell hasseen_outside. A dungeon (seen_outside == 0everywhere) shows no terrain/sky.
This is a single binary on the current cell's id range + seen_outside. No
separate "is it raining indoors?" logic — rain/sky come through the doorway
because the landscape is drawn through the exit-portal clip.
C13. Is render's cell the SAME curr_cell/graph as physics?
Yes — render reads the same objcell_id and traverses the same cell graph.
[VERIFIED]
CellManager::ChangePosition @ 0x004559b0 (pseudo_c:94601) [VERIFIED] is the
render-side cell tracker, driven by the position's objcell_id (the same id
physics maintains):
uint objcell_id = arg2->objcell_id; // the Position's cell id
if (objcell_id == 0) { CellManager::Reset(this); return; }
...
if (load_pos.objcell_id != objcell_id || curr_cell == 0) {
CellManager::PreFetchCells(this, objcell_id, edi);
...
CObjCell* eax_2 = CObjCell::Get(arg2->objcell_id); // :94640 RESOLVE from the same id
if (eax_2) {
if (ebp_2) { eax_2->grab_visible_cells(); curr_cell = eax_2; }
else if (eax_2->seen_outside || keep_lscape_loaded) { // :94649 keep landscape if seen_outside
LScape::update_loadpoint(lscape, Position::get_outside_cell_id(arg2));
eax_2->grab_visible_cells(); curr_cell = eax_2;
} else { LScape::release_all(lscape); eax_2->grab_visible_cells(); curr_cell = eax_2; }
}
...
Render::player_pos.objcell_id = curr_cell->pos.objcell_id; // :94671
}
The render CellManager.curr_cell is CObjCell::Get(position.objcell_id) — the
same cell graph (CObjCell::GetVisible/CEnvCell::visible_cell_table) the
physics uses (find_cell_list resolves GetVisible(objcell_id) too,
pseudo_c:308754/308756). The viewer's cell (SmartBox.viewer_cell) is the
camera's cell; in 1st person it is the player's cell; in 3rd person the camera
may be in a neighbour cell, but it is resolved through the same graph.
CObjCell::GetVisible @ 0x0052ad40 (pseudo_c:308209) [VERIFIED] is the shared
resolver:
return (arg1 >= 0x100) ? CEnvCell::GetVisible(arg1) : CLandCell::GetVisible(arg1);
CEnvCell::GetVisible @ 0x0052dc10 (pseudo_c:311378) is a hashtable lookup in
CEnvCell::visible_cell_table; CLandCell::GetVisible @ 0x00532db0 (pseudo_c:316986) → LScape::get_landcell. [VERIFIED]
Conclusion (central to acdream's decision): retail has one cell graph
and one objcell_id. Physics maintains curr_cell; render reads
position.objcell_id and resolves through the same GetVisible. There is no
separate render-side cell discovery from the camera XYZ — the render cell is
the physics cell (for the player/viewer). The PVS traversal (PView) then
expands from that single root through the shared CEnvCell::portals.
D. Synthesis for acdream
D-pre. What acdream does today (the gap, precisely)
Read in source [VERIFIED]:
-
The sweep already tracks the cell correctly. acdream's
SpherePathhasCurCellId+CheckCellId(src/AcDream.Core/Physics/TransitionTypes.cs:335-336), and itsValidateTransitionadvances/resets them exactly like retail:// TransitionTypes.cs:3404-3434 if (transitionState == OK && sp.CheckPos != sp.CurPos) { sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); } else if (transitionState == OK) { sp.SetCheckPos(sp.CurPos, sp.CurCellId); } else if (transitionState != Invalid) { ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); transitionState = OK; }It also retargets
CheckCellIdto the containing cell mid-sweep (TransitionTypes.cs:2061-2075, the Phase A4FindCellSet+SetCheckPos(sp.CheckPos, containingCellId)). -
The engine THROWS AWAY the tracked cell and re-derives statically. This is the bug.
PhysicsEngine.ResolveWithTransitionbuilds the result:// PhysicsEngine.cs:907-912 (OK path) resolveResult = new ResolveResult( sp.CheckPos, ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), // :909 RE-DERIVE, sp.CheckCellId only a fallback onGround, collisionNormalValid, collisionNormal); // PhysicsEngine.cs:926-931 (partial/blocked path) resolveResult = new ResolveResult( sp.CheckPos, ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), // :928 RE-DERIVE ...);ResolveCellId @ PhysicsEngine.cs:294then re-runsCellTransit.FindCellList(indoor) or a terrain-grid lookup (outdoor) from the static endpoint, and even re-verifies with a sphere-overlap test + a "doorway hold margin" band-aid (PhysicsEngine.cs:347-363, 402-421). This is the static re-derive the prompt identified: jitter at the boundary flips the answer. -
No
do_not_load_cellsprune.CellTransit.BuildCellSetAndPickContaining(CellTransit.cs:426) builds the candidate set and picks the first candidate whosePointInsideCellBspis true (:524-534) — with no interior-wins short-circuit and no stab-list prune. HashSet iteration order makes the pick non-deterministic when multiple cells contain the center. -
Render maintains a SEPARATE cell system.
CellVisibility.FindCameraCell(src/AcDream.App/Rendering/CellVisibility.cs:389) independently re-derives the camera cell from the eye position, with a 3-frame grace-frame hack (CellSwitchGraceFrameCount = 3,:214), ported from ACME's flat-stencilEnvCellManager— not retail's PView.GetVisibleCells(:521) is a separate BFS. This is the root of the "render strobes / blue hole / bleed" family. -
The unified seam already exists. W1 shipped
CellGraph(src/AcDream.Core/World/Cells/CellGraph.cs) withGetVisible(id)(the retail resolver) andCurrCell(the single membership answer), plus faithfulEnvCell(StabList,Portals,SeenOutsidefromEnvCellFlags.SeenOutside,ContainmentBsp,PointInCell).CurrCellis currently written bySetCurrAndReturn(PhysicsEngine.cs:287) but from the re-derived id, and it has no render reader yet.
D14. The retail-faithful target architecture
Adopt retail's "track membership through the sweep; one cell graph for physics + render; one portal-visibility traversal" model. Concretely:
-
Membership is a latch tracked through the sweep, committed at the end.
ResolveWithTransitionmust returnsp.CurCellId(the value advanced byValidateTransition/ retargeted by the mid-sweep containing-cell pick), not a re-derive from the final XYZ. The staticResolveCellIdis deleted from the per-tick output path. This mirrorsCPhysicsObj::SetPositionInternal(CTransition*)readingsphere_path.curr_cell(pseudo_c:283403) andchange_cell-on-differ (:283414/283456). -
PlayerMovementController.CellIdbecomes "set fromsp.CurCellId, change only on differ." Add an explicit "did the cell change?" event so the render and streaming can react (the analog ofchange_cell/CellManager::ChangePosition). WriteCellGraph.CurrCell = GetVisible(newId)here — once, authoritatively. -
CellTransitgets the interior-wins short-circuit + thedo_not_load_cellsprune. InBuildCellSetAndPickContaining: when a candidate is interior and contains the center, pick it and break (find_cell_list:308814-308819); and when the seed is interior, prune every candidate not equal to the seed cell and not in the seed cell'sStabList(find_cell_list:308829-308867). This restores membership stability and makes the pick deterministic — and lets us delete theDoorwayHoldMarginband-aid (PhysicsEngine.cs:416-421) and the sphere-overlap re-verify (:347-363), since the latch + prune make them unnecessary. -
Render obeys the physics
CurrCelland runs ONE portal-visibility traversal. ReplaceCellVisibility.FindCameraCell's independent re-derive + grace-frames with: root the PVS at the player'sCellGraph.CurrCell(camera/eye still drives projection, but the visibility ROOT is the physics cell — this is exactly the flap fix already discovered, CLAUDE.md U.4c). For the camera in 3rd person, resolve the camera's cell through the sameCellGraph.GetVisibleif needed, but prefer the player cell as root (retail'sviewer_cellis the camera cell, but acdream's chase camera is the source of the stale-cell flap — keep root = player cell, per the shipped U.4c decision). Then port thePView::ConstructViewBFS to produce one visible cell list + exit-portal clip regions, and one decision (SmartBox::RenderNormalMode): outdoor landcell → terrain pipeline; EnvCell → indoor pipeline that draws the landscape through exit portals whenseen_outside. -
Terrain/sky gating keys off the current cell's id range +
seen_outside, exactly once.(CurrCell.Id & 0xFFFF) < 0x100→ draw terrain/sky.EnvCell && seen_outside→ draw terrain/sky only through exit-portal clip regions.EnvCell && !seen_outside(dungeon) → never draw terrain/sky. This replaces the abandoned two-pipe (cameraInsideBuilding) split.
D15. Should membership be advanced in the sweep, and should render obey it?
Yes to both. Justified directly from the decomp:
-
Advance membership inside the sweep (port
validate_transition'scurr_celladvance + the prune + drop the static re-derive):validate_transitionis the only place retail mutatescurr_cell, and it does so as a function of whether the swept sub-step was accepted (pseudo_c:272612advance vs:272593reset).SetPositionInternalreads exactly that (pseudo_c:283403) and commits viachange_cell-on-differ. There is no static endpoint re-derivation anywhere in retail's per-tick path —find_cell_listis called during the sweep (incheck_other_cells) and at the end witharg5 == nullptr(so it does not pick membership). Thedo_not_load_cellsprune is part of that samefind_cell_list(pseudo_c:308829). acdream already has the latch infrastructure (sp.CurCellId+ValidateTransition); the fix is to use it at the engine output and add the prune. -
Render obeys the physics
curr_cell+ a single portal traversal:CellManager::ChangePositionresolves render'scurr_cellfromposition.objcell_idviaCObjCell::Get— the same graph and id physics maintains (pseudo_c:94640).SmartBox::RenderNormalModekeys the single inside/outside decision offviewer_cell(pseudo_c:92649,92665).PViewexpands from that one root. acdream's separateCellVisibility.FindCameraCellwith grace-frames is non-retail and is the documented source of the flap/strobe; rooting the PVS at the sharedCurrCell(W2/W3) is the retail-faithful replacement. [VERIFIED across decomp + ACE + acdream code]
The risk the prompt flags ("touching the collision sweep where acdream has a
history of bugs") is largely avoided by this plan: the sweep itself is
not changed (its CurCellId tracking is already correct and tested). We
change (a) the engine output (return sp.CurCellId), (b) add a prune in
CellTransit pick, and (c) the render root. None of these alter
insert_into_cell / FindEnvCollisions / step-up / AdjustOffset — the parts
that have historically broken (issue #98 etc.). This is a key safety property.
D16. Must-port functions, integration order, risks, conformance tests
Must-port / must-align functions (with decomp addresses)
Physics membership (mostly already present in acdream — align, don't rewrite):
| Retail function | Address (pseudo_c) | acdream status / action |
|---|---|---|
CTransition::validate_transition |
0x0050aa70 (272547) |
Present as ValidateTransition (TransitionTypes.cs:3398). Keep. |
CObjCell::find_cell_list |
0x0052b4e0 (308742) |
Present as CellTransit.BuildCellSetAndPickContaining. Add interior-wins break + do_not_load_cells prune (:308814-308867). |
CTransition::check_other_cells |
0x0050ae50 (272717) |
Present as CheckOtherCells + mid-sweep retarget (TransitionTypes.cs:2061-2075). Keep; ensure exit→adjust_to_outside path exists. |
CPhysicsObj::SetPositionInternal(CTransition*) |
0x00515330 (283399) |
Port the read-CurCell/commit-on-differ contract into ResolveWithTransition — return sp.CurCellId, drop static ResolveCellId at PhysicsEngine.cs:909/928. |
CPhysicsObj::change_cell |
0x00513390 (281192) |
acdream uses a spatial registry, not per-cell shadow lists. Emit a "cell changed" event when sp.CurCellId differs; align registration with retail's per-cell shadow_object_list over time (see memory feedback_retail_per_cell_shadow_list.md). |
LandDefs::adjust_to_outside |
0x005a9bc0 (438719) |
Needed for the interior→outdoor exit id (check_other_cells:272783). Verify acdream's AddAllOutsideCells / Position.get_outside_cell_id equivalent covers this. |
CBuildingObj::find_building_transit_cells |
0x006b5230 (701214) |
Present as CheckBuildingTransit. Keep. |
CEnvCell::find_transit_cells (sphere) |
0x0052c820 (309968) |
Present as FindTransitCellsSphere. Keep. |
CLandCell::add_all_outside_cells |
0x00533630 (317499) |
Present as AddAllOutsideCells. Keep (see memory feedback_latent_bug_masked_by_fallback.md re: coord convention). |
Render visibility (port fresh — this is the unified-pipeline work, Phase U/W3):
| Retail function | Address (pseudo_c) | acdream action |
|---|---|---|
SmartBox::RenderNormalMode |
0x00453aa0 (92635) |
Port the single inside/outside decision keyed off CurrCell id-range + seen_outside. |
CellManager::ChangePosition |
0x004559b0 (94601) |
Port: render resolves cell from objcell_id via CellGraph.GetVisible; grab_visible_cells on change; keep-landscape when seen_outside. |
CEnvCell::grab_visible_cells |
0x0052e220 (311878) |
Port: add self+stab_list to visible set; load landscape iff seen_outside. |
PView::ConstructView (cell) |
0x005a57b0 (433750) |
Port the BFS visible-cell-list builder. |
PView::InitCell |
0x005a4b70 (432896) |
Port per-portal sidedness/clip init. |
PView::ClipPortals |
0x005a5520 (433572) |
Port; the other_cell_id == 0xffffffff branch (:433662) is the landscape-through-doorway hook. |
PView::AddViewToPortals |
0x005a52d0 (433446) |
Port neighbour enqueue + SetOtherSeen. |
PView::DrawCells |
0x005a4840 (432709) |
Port: if outside_view.view_count>0 → LScape::draw first; conditional Z-clear (NOT color); per-cell drawing_bsp; exit-portal stencil. |
PView::DrawInside |
0x005a5860 (433793) |
Port the top-level indoor entry. |
PView::ConstructView (bld portal) |
0x005a59a0 (433827) |
Port the outdoor→building recursion (camera sees door from street). |
Integration order (lowest-risk first)
- W2/W3-physics: stop discarding the swept cell. Make
ResolveWithTransitionreturnsp.CurCellId; route it toPlayerMovementController.CellIdandCellGraph.CurrCell. Delete the staticResolveCellIdcall at:909/928(keepResolveCellIdonly for non-sweep teleport/spawn seeding). Smallest, highest-leverage change; does not touch collision math. Expect the boundary flicker to vanish immediately. - CellTransit pick fix. Add interior-wins short-circuit + the
do_not_load_cellsprune inBuildCellSetAndPickContaining. Then delete theDoorwayHoldMarginhold and the sphere-overlap re-verify inResolveCellId(now dead for the per-tick path). Removes band-aids; makes pick deterministic. - Render reads
CurrCell. Re-rootCellVisibilityatCellGraph.CurrCell(player cell), removeFindCameraCell's independent re-derive + the 3-frame grace hack. This alone fixes the threshold strobe. - Single PVS + landscape-through-door. Port
PView::ConstructView/DrawCellsso the indoor pass draws the landscape through exit-portal clips (kills the blue hole) and the singleRenderNormalModedecision replaces the two-pipe split. Largest piece; do last, gated on visual verification. - (Longer term) per-cell shadow lists. Migrate collision registration from
the landblock-wide spatial registry to retail's per-cell
shadow_object_listto close the indoor/outdoor seam issues noted in memory.
Risks
- Velocity/position consumers of the cell. Anything that today reads the
re-derived cell (streaming radius, audio cell, picking) must switch to
CurrCell. AuditSetCurrAndReturncall sites. Mitigation: make the change event explicit; one writer. - Spawn/teleport seeding still needs a static resolve. Keep
ResolveCellIdforMoveOrTeleport-equivalent seeding (retail'sSetPositionInternal(Position*,…)path atpseudo_c:283892also resolves from scratch). Don't delete it wholesale — only remove it from the per-tick transition output. do_not_load_cellsprune over-pruning. If acdream'sStabListfor a cell is incomplete (dat parse gap), the prune could drop a legitimately-overlapping neighbour and cause a wall to be missed. Mitigation: the prune only runs for interior seeds and only removes non-stab cells; verifyEnvCell.StabListpopulation againstdatCell.VisibleCells(already done inEnvCell.FromDat).- PVS perf / portal-graph blowup (issue #95). Port
master_timestamp+cell_view_donecycle-guards fromPViewto bound the BFS; cap withmax_indist. This is exactly what acdream's separate BFS lacks. - Render root vs camera. Retail's
viewer_cellis the camera cell; acdream's chase camera drifts out of the player cell ~79% of frames (CLAUDE.md U.4c), so root the PVS at the player cell (the shipped flap fix), keeping the eye for projection only. Do not regress to camera-rooted visibility.
Conformance tests (prove faithfulness)
- Membership latch (no static re-derive). Replay the captured cottage/
cellar trajectory (
CellarUpTrajectoryReplayTests+ACDREAM_CAPTURE_RESOLVEJSONL) and assert the per-tick output cell equals the sweptsp.CurCellId, never a re-derive. Specifically: a held-still tick at the doorway must keep the prior cell (no0x0170↔0x0031flip). Golden source: live capture shows 1 cell-transit (login) vs 20+ pre-fix (CLAUDE.md A6.P3 slice 3). - Block does not change cell. Unit test: seed
CurCell=A, run a transition whose only accepted sub-steps stay in A but whose final push-back nudges the center ~8 cm across the A/B boundary; assertsp.CurCellId == Aand the committed cell == A. (Directly testsvalidate_transitionreset path.) - Interior-wins pick. Unit test: sphere center inside both a doorway
landcell and the vestibule EnvCell; assert
BuildCellSetAndPickContainingreturns the EnvCell and short-circuits. do_not_load_cellsprune. Unit test: interior seed cell with a knownStabList; inject an outdoor candidate not in the stab list; assert it is removed. Mirror ACE'sfind_cell_list:387-412.- Exit-to-outdoor id. Unit test: from an interior cell with an exit portal,
walk the foot sphere out the door; assert
check_other_cellsproduces the correct outdoor landcell id viaadjust_to_outside. - Render PVS == physics cell. Integration test: assert the PVS root cell id
equals
CellGraph.CurrCell.Idevery frame (no independent camera re-derive). - Seamless seal (visual + headless). Headless: in a cottage with a known
exit portal, assert
PView.outside_view.view_count > 0and thatLScape::drawequivalent is invoked (terrain drawn) — i.e. no blue clear-color in the doorway region. Visual: the user confirms sky/rain visible through the door, ceiling sealed, no terrain bleed. (Visual verification is the acceptance gate per CLAUDE.md.) - Dungeon: no terrain/sky. Headless: in a
seen_outside == 0EnvCell graph (dungeon), assert the landscape is never grabbed (grab_visible_cellsreturns beforeLScape::grab_visible_cells) andoutside_view.view_count == 0.
Appendix — disagreements & uncertainties
seen_outsidefield overload inCEnvCell::UnPack. Atpseudo_c:311044the decompiler showsthis->seen_outside = <operator new[]>and indexes it with frames — inconsistent with the verbatim header'sint seen_outside. This is BN's heuristic field-naming colliding with an adjacent allocation (likely thevoyeur_table/light-frame array). The authoritative facts are (a) the verbatim headerint seen_outside(acclient.h:30929), (b) its boolean use in render (pseudo_c:92649,94575, 311893), and (c) the dat flagEnvCellFlags.SeenOutside = 0x1. I treatseen_outsideas a per-cell boolean. [flagged as decomp-naming noise]edi_2inRenderNormalMode. The decompiler garbles the "viewer is in an outdoor landcell" predicate (pseudo_c:92644-92646). The branch is clearly binary (edi_2 == 0→DrawInside, elseLScape::draw), and theviewer_cell->seen_outsideterm is intact. I inferedi_2 != 0⇔ viewer cell is a landcell. [INFERRED from branch structure + the parallelCellManager::ChangePositionebx = (objcell_id < 0x100)test atpseudo_c:94568]do_not_load_cellsset-site for the player transition. I verified the flag's effect (find_cell_list:308829) and ACE'sLoadCellssemantics, but did not pin the exact retail call that sets it on the per-tick player array. The prune is unconditionally correct for interior seeds, so acdream can enable it for interior membership resolution regardless. [INFERRED]- References diverge on the seal. ACViewer (brute-force draw all EnvCells +
DungeonModecull toggle) and WorldBuilder (flat stencil inside-out) do not implementPView's portal-clipped landscape-through-door. The decomp is the sole authority for C9–C11, and it wins. [VERIFIED divergence]
Citation index (primary decomp anchors)
CPhysicsObj::transition 0x00512dc0 pseudo_c:280904
SPHEREPATH::init_path 0x0050ce20 pseudo_c:274359
CTransition::find_valid_position 0x0050c310 pseudo_c:273890
CTransition::find_transitional_position 0x0050bdf0 pseudo_c:273613
CTransition::transitional_insert 0x0050b6f0 pseudo_c:273137
CTransition::insert_into_cell 0x00509e70 pseudo_c:271991
CTransition::check_other_cells 0x0050ae50 pseudo_c:272717
CTransition::validate_transition 0x0050aa70 pseudo_c:272547
CObjCell::find_cell_list (6-arg) 0x0052b4e0 pseudo_c:308742
CObjCell::GetVisible 0x0052ad40 pseudo_c:308209
CEnvCell::GetVisible 0x0052dc10 pseudo_c:311378
CLandCell::GetVisible 0x00532db0 pseudo_c:316986
CEnvCell::point_in_cell 0x0052c300 pseudo_c:309677
CLandCell::point_in_cell 0x00532d40 pseudo_c:316941
CEnvCell::find_transit_cells (sphere) 0x0052c820 pseudo_c:309968
CLandCell::find_transit_cells 0x00533800 pseudo_c:317603
CSortCell::find_transit_cells 0x00534060 pseudo_c:318309
CBuildingObj::find_building_transit_cells 0x006b5230 pseudo_c:701214
CLandCell::add_all_outside_cells 0x00533630 pseudo_c:317499
LandDefs::adjust_to_outside 0x005a9bc0 pseudo_c:438719
Position::get_outside_cell_id 0x004527b0 pseudo_c:91552
CPhysicsObj::SetPositionInternal(CTrans)0x00515330 pseudo_c:283399
CPhysicsObj::change_cell 0x00513390 pseudo_c:281192
CPhysicsObj::UpdateObjectInternal 0x005156b0 pseudo_c:283611
CEnvCell::find_visible_child_cell 0x0052dc50 pseudo_c:311397
CEnvCell::grab_visible_cells 0x0052e220 pseudo_c:311878
SmartBox::RenderNormalMode 0x00453aa0 pseudo_c:92635
CellManager::ChangePosition 0x004559b0 pseudo_c:94601
PView::ConstructView (CEnvCell) 0x005a57b0 pseudo_c:433750
PView::ConstructView (CBldPortal) 0x005a59a0 pseudo_c:433827
PView::InitCell 0x005a4b70 pseudo_c:432896
PView::ClipPortals 0x005a5520 pseudo_c:433572
PView::AddViewToPortals 0x005a52d0 pseudo_c:433446
PView::DrawCells 0x005a4840 pseudo_c:432709
PView::DrawInside 0x005a5860 pseudo_c:433793
Structs (acclient.h):
SPHEREPATH 32625 | CObjCell 30915 | CEnvCell 32072 | CLandCell 31886
CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 | CELLARRAY 31574
CELLINFO 31925 | CLandBlockInfo 31893 | CBuildingObj 31908 | portal_view_type 32346
References cross-checked:
ACE ObjCell.find_cell_list references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335
ACE Transition.ValidateTransition references/ACE/Source/ACE.Server/Physics/Transition.cs:984
ACE PhysicsObj.SetPositionInternal references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171
ACE Landblock.IsDungeon references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575
ACViewer EnvCellFlags references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7
ACViewer Buffer (no PVS) references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564
acdream current code:
PhysicsEngine.ResolveCellId src/AcDream.Core/Physics/PhysicsEngine.cs:294
PhysicsEngine static re-derive src/AcDream.Core/Physics/PhysicsEngine.cs:909,928
SpherePath CurCellId/CheckCellId src/AcDream.Core/Physics/TransitionTypes.cs:335-336
ValidateTransition (advance/reset) src/AcDream.Core/Physics/TransitionTypes.cs:3398-3434
CellTransit pick (no prune) src/AcDream.Core/Physics/CellTransit.cs:426-538
CellVisibility.FindCameraCell src/AcDream.App/Rendering/CellVisibility.cs:389 (+ grace :214)
CellGraph (unified, W1) src/AcDream.Core/World/Cells/CellGraph.cs
EnvCell.FromDat (StabList/SeenOutside) src/AcDream.Core/World/Cells/EnvCell.cs:42