The Phase W indoor seal did NOT land. The 2026-06-02 visual gate proved the interior render is fundamentally broken (#78: transparent walls, outdoor terrain + scenery entities bleeding in, grey floors, no outside-looking-in). Stage 4 (sky-through-door clip) was real but a top layer on a base that never sealed. DECISIVE EVIDENCE (committed in the handoff): the PVS computes correctly AND the cell shells render correctly (opaque, textured, complete — the [shell] probe shows zero NOSNAP / zero missing-texture). The failure is the SEAL + three inconsistent gates — concretely the WbDrawDispatcher.cs:1756 ParentCellId==null -> return true bypass draws outdoor scenery indoors, and the indoor path draws the outdoor world then gates it instead of running ONLY DrawInside. Retail, when inside, runs ONE PView flood: visibility IS the cull; the landscape enters only through clipped exit portals + a conditional depth-only clear. Dossier (per the user's mandate: NO shortcuts/bandaids, port from retail, redesign the whole pipeline if needed, brainstorm first): - Master handoff (root cause + retail target + reusable-vs-redesign + apparatus + do-not-repeat + copy-paste pickup prompt). - Huge staged redesign plan R0(brainstorm)->R1(one visibility authority, kill the bleed)->R2(indoor=DrawInside-only)->R3(the seal, DrawCells port)->R4(per-cell object/particle clip)->R5(outside-looking-in)->R6(dungeons)->R7(polish/conformance). Each ends at a user visual gate. - 3 research docs: full retail render pipeline reference (705 lines, decomp-verified), acdream pipeline inventory + failure map, reference cross-check (WB two-pipe is the wrong model). #78 promoted to the redesign. The 5 remaining Core test failures are pre-existing physics/collision bugs, none render-related. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
48 KiB
Retail AC — the FULL render pipeline (outdoor + indoor + dungeon + seal), implementable reference
Purpose. The single, exhaustive, port-ready reference for how the retail AC client (Sept 2013 EoR build, PDB-named) renders the world: outdoor landscape, building interiors, dungeons, portals, the "seal" (drawing the outside through a doorway with no blue hole, capped ceilings, no wall-bleed), and object/particle clipping. This is the foundation of the Phase W / Phase U unified-render redesign. The code is modern; the behavior is retail — port this faithfully, with no shortcuts.
Sources. Consolidates the four 2026-06-02 decomp studies (
-opus48-a,-opus48-b,-sonnet46,-codex) and the approved design spec (docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md), then deepens the DRAW/seal pipeline with the actual pseudo-C read this session fromdocs/research/named-retail/acclient_2013_pseudo_c.txtand verbatim structs fromdocs/research/named-retail/acclient.h. Citations areClass::method @ 0xADDR (pc:LINE)for decomp andacclient.h:LINEfor structs. Membership/transition material (Section A) is deliberately summarized — it is fully covered in the four studies and is not the render redesign's spine; the render pipeline (Sections 2–7) is the new, deep material.
1. Executive summary — the ONE retail pipeline (≤12 bullets)
-
One cell graph, one membership answer, render obeys it. Physics tracks the player's cell as
SPHEREPATH::curr_cellcarried through the collision sweep; the camera tracks its ownviewer_cellvia a second (spring-arm) transition. Both resolve through the sameCObjCell::GetVisible(objcell_id)graph. There is no separate render cell system, no static "re-derive the cell from XYZ", and no "underground" boolean. -
Top-level decision is binary, per frame (
SmartBox::RenderNormalMode @ 0x00453aa0, pc:92635).is_player_outside/viewer-cell test → outdoor path =LScape::draw(full terrain+sky+buildings); EnvCell path =RenderDevice::DrawInside(viewer_cell). It is not "landscape then inside" at the top level — when inside, onlyDrawInsideruns. -
DrawInside(PView) is one portal-flood traversal (PView::DrawInside @ 0x005a5860→ConstructView @ 0x005a57b0→DrawCells @ 0x005a4840). It produces an orderedcell_draw_list(visible EnvCells) plus per-cell screen clip regions (portal_view) and one accumulatedoutside_view(the outdoors seen through exit portals). -
The landscape is pulled INTO the indoor traversal through exit portals. A portal with
other_cell_id == 0xffffffffis an exit portal.ClipPortals(@ 0x005a5520, pc:433662) copies its screen clip region intothis->outside_view(gated bydraw_landscape). This is the seam that makes the outside visible through a doorway. -
The seal sequence in
DrawCells(pc:432715+), whenoutside_view.view_count > 0: (a)LScape::draw(lscape)withRender::PortalList = this→ terrain+sky+rain+exterior, clipped to the doorway; (b) a conditional Z-only clearClear(4, …)(flag 4 = Z buffer, NOT color; conditional onportalsDrawnCount); (c) per-cell exit-portal stencil viaDrawPortalPolyInternal; (d) per-cellDrawEnvCell(the closed interior geometry); (e) per-cellDrawObjCellForDummieswithPortalListset to that cell's view (objects). -
There is no blue clear-color hole, by construction. The only clear in the indoor path is
Clear(4, …)= depth only, and even that is conditional. The doorway shows real terrain/sky because the landscape was drawn first, clipped to the exit-portal region. -
Ceilings/walls/floors are sealed by the dat geometry. Each EnvCell's
drawing_bspis a closed box (floor + walls + ceiling) with holes only whereCCellPortals exist. There is no "cap the ceiling" step;DrawCellsdraws each visible cell'sdrawing_bsp. Portal openings are masked, not filled. -
Visibility IS the cull. Only cells reached by the portal BFS are in
cell_draw_list; only their objects (object_list, drawn per-cell withPortalListset) are drawn. An object/ particle in a non-visible cell is never iterated → no wall bleed-through. Membership comes from the same physicsenter_cell/leave_cellgraph. -
Outside-looking-in is the mirror image, same machinery. While drawing the landscape, when the camera can see a building's door,
PView::DrawPortal @ 0x005a5ab0runsConstructView(CBldPortal, polygon, …) @ 0x005a59a0— a viewer-vs-portal-plane side test, aGetClip, then it recursesConstructView(interior_cell, other_portal_id)andDrawCellsthe interior through the door's clip region. Outside↔inside is one recursive portal-clipped traversal over the shared graph. -
Dungeons are emergent, not flagged. A dungeon is an all-EnvCell landblock (terrain heights 0, ≥1 EnvCell, no buildings) where every cell has
seen_outside == 0and no exit portals. Sooutside_view.view_countstays 0,LScape::drawis never reached, and there is no terrain/ sky — automatically. SameDrawInsidepath as a cottage interior. -
Terrain/sky/landscape state keys off
seen_outside.CellManager::ChangePosition @ 0x004559b0keeps the landscape loaded + sunlight/outdoor-ambient live iff the current cell is a landcell orCObjCell::seen_outsideis set; otherwise itrelease_alls the landscape and uses flat indoor ambient.CEnvCell::grab_visible_cells @ 0x0052e220loads the landscape iffseen_outside. -
BFS convergence is watermark-bounded, not capped.
ConstructViewuses a per-cellportal_view_type.update_countwatermark + acell_todo_listworklist; a cell can be re-processed only for genuinely new view slices (update_count → view_count). This is the retail replacement for acdream's fixedMaxReprocessPerCellcap (issue #102) and the right fix for dungeon PVS blowup (#95).
2. Render-decision tree — outdoor / indoor / both
2.1 The single per-frame gate: SmartBox::RenderNormalMode @ 0x00453aa0 (pc:92635)
Verbatim control flow (pc:92642-92684), with the decompiler-garbled predicate named:
if (render_device->m_bOpenScene != 0) {
// edi_2 == SmartBox::is_player_outside(this)-equivalent: viewer is in an OUTDOOR landcell
// ebx_1 == "outside is relevant" = (viewer outside) || (viewer_cell->seen_outside != 0)
if (edi_2 != 0 || this->viewer_cell->seen_outside != 0) ebx_1 = 1; else ebx_1 = 0;
// FOV / view-distance setup (omitted) ...
if (edi_2 == 0) { // ── VIEWER IS INSIDE AN ENVCELL ──
if (ebx_1 != 0) { // cell can see outside:
uint eax = Position::get_outside_cell_id(&this->viewer); // pc:92669
LScape::update_viewpoint(this->lscape, eax); // pre-position terrain
}
Render::update_viewpoint(&this->viewer);
render_device->vtable->DrawInside(render_device, this->viewer_cell); // pc:92675 PView
} else { // ── VIEWER IS OUTSIDE (landcell) ──
LScape::update_viewpoint(this->lscape, this->viewer.objcell_id); // pc:92679
Render::update_viewpoint(&this->viewer);
Render::set_default_view();
Render::useSunlightSet(1);
LScape::draw(this->lscape); // pc:92683 full outdoor render
}
}
D3DPolyRender::FlushAlphaList(0);
// ... target bounding-box callback + rendering callback ...
Key facts:
- Exactly two branches, chosen by
edi_2= "is the viewer cell an outdoor landcell?" When outside, the only draw isLScape::draw. When inside, the only draw isDrawInside— the landscape, if shown, is drawn insideDrawInside/DrawCells(Section 4), not here. ebx_1(= outside-relevant) controls whether the landscape's viewpoint is updated before the indoor draw, so that if a doorway shows terrain it is centered on the right landblock. The actual terrain draw-through-door decision isoutside_view.view_count > 0insideDrawCells.- The predicate maps to
SmartBox::is_player_outside @ 0x00451e80(pc:90996):
Outdoor landcell ids areint is_player_outside(SmartBox* this) { if (this->player == 0) return 0; uint lowWord = (uint16_t)this->player->m_position.objcell_id; // pc:91004 return lowWord < 0x100; // (decompiler garbles the compare; semantics: outdoor iff < 0x100) }0x0001..0x0040; EnvCell ids are>= 0x0100. This low-word test is the type discriminator used everywhere (find_cell_listpc:308753;GetVisiblepc:308209).
2.2 The decision matrix (port this exactly)
| Viewer cell | seen_outside |
Top-level draw | Terrain/sky? |
|---|---|---|---|
Outdoor landcell (id&0xFFFF < 0x100) |
n/a | LScape::draw (full) |
Yes (always). Buildings recurse via CBldPortal/DrawPortal. |
| EnvCell (cottage/inn interior) | 1 | DrawInside |
Only through exit portals (outside_view>0 → LScape::draw clipped to doorway). |
| EnvCell (dungeon / sealed room) | 0 | DrawInside |
No (no exit portal reachable → outside_view==0 → LScape::draw never called). |
There is no "both at once" at the top level. "Both" happens only inside the indoor path:
DrawCells draws the landscape (through exit portals) then the interior cells, in one pass.
2.3 RenderDeviceD3D::DrawInside @ 0x0059f0d0 (pc:427843) — thin forwarder
return PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2); // pc:427847
The render device owns two persistent PView instances: indoor_pview (rooted at the viewer
cell) and outdoor_pview (used by DrawPortal for outside-looking-in, Section 6.2).
3. PView traversal — step by step
PView ("portal view") is the heart of indoor rendering: a breadth-first (worklist) portal flood
over the shared CEnvCell graph that yields one ordered visible-cell list + per-cell clip regions.
3.1 Entry: PView::DrawInside @ 0x005a5860 (pc:433793)
Render::object_scale = 1; /* object_scale_vec = (1,1,1) */
CEnvCell::curr_view_push(viewer_cell); // pc:433800 push a view slot
PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // pc:433801 seed PVS from stab list
Frame::cache(&identityFrame); Render::positionPush(3, &identityFrame);
Render::copy_view(viewer_cell->portal_view[num_view-1], nullptr, 4); // pc:433814 seed root view = full screen
ConstructView(this, viewer_cell, 0xffff); // pc:433817 build visible set
PView::DrawCells(this, …); // pc:433819 draw it
Render::framePop();
PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list);
viewer_cell->num_view -= 1; // pop the view slot
add_views/remove_views (@ 0x005a5210) push/pop a per-cell portal_view_type slot for every
cell in the root's stab list, so the BFS has somewhere to accumulate clip regions for those cells.
3.2 The BFS: PView::ConstructView(CEnvCell*, ushort) @ 0x005a57b0 (pc:433750)
Verbatim:
this->outside_view.view_count = 0; // reset the outdoor-through-portal accumulator
PView::master_timestamp += 1; // new traversal stamp (cycle guard)
this->cell_todo_num = 0; this->cell_draw_num = 0;
PView::InitCell(this, root, portalId); // compute root's per-portal in/seen flags + clip
PView::InsCellTodoList(this, root, 0); // push root onto the worklist
while (true) {
if (cell_todo_num <= 0) return;
cell = cell_todo_list[--cell_todo_num]->cell; // POP (LIFO: index num-1)
if (cell == 0) return;
// append to the visible draw list (grow by 0x1e if needed):
cell_draw_list[cell_draw_num++] = cell; // pc:433783
cell->portal_view[cell->num_view - 1]->cell_view_done = 1; // pc:433784
if (PView::ClipPortals(this, cell, 0) != 0) // pc:433786 clip this cell's portals
PView::AddViewToPortals(this, cell); // pc:433787 enqueue visible neighbours
}
Outputs: cell_draw_list[0..cell_draw_num] (visible EnvCells in pop order), each cell's
portal_view[...]->view (accumulated screen clip polys), and this->outside_view (exit-portal
clip regions). Note it is technically a LIFO worklist (a stack), but converges to the same visible
set; "BFS" is used loosely in the studies.
3.3 PView::InitCell @ 0x005a4b70 (pc:432896) — per-portal sidedness + clip init
For a cell's current view slot (portal_view[num_view-1]):
- Early-out if
esi[0xe] == 0(no active view —update_count/view_countempty). pc:432903. Render::positionPush(3, &cell->pos)to enter cell-local space.- For each portal: compute the portal polygon's plane vs the viewer viewpoint
(
Render::FrameCurrent->viewer.viewpoint), set per-portalseen/inflag(portal_info { int seen; int inflag; }, acclient.h:32459) according toportal_side— i.e. which portals face the viewer and can be seen through. Computemax_indistand setupdate_count = view_count(the watermark — see §5.4). This is "which of my portals are live for this view slice."
3.4 PView::AddToCell @ 0x005a4d90 (pc:433050) — incremental view-slice merge
When a cell is reached again through a different portal (a second clip region), AddToCell
processes only the new view slices, from the cell's update_count watermark up to its current
view_count (pc:433056-433080). This is how a far cell seen through two doorways accumulates two
clip regions without re-walking the slices it already has — the watermark guarantees each slice is
expanded once.
3.5 PView::ClipPortals @ 0x005a5520 (pc:433572) — clip portals + feed outside_view
Two phases. Phase 1 (pc:433583-433620): for each portal with seen != 0 && inflag != 1,
resolve other_cell_ptr (via CEnvCell::GetVisible(other_cell_id), cached into the portal); set a
local var_c = 1 if the portal has any destination — a loaded neighbour, or the exit
sentinel other_cell_id == 0xffffffff, or a resolvable neighbour. If var_c == 0 (no portal goes
anywhere) the function returns 0 → the cell contributes no neighbours.
Phase 2 (pc:433622-…): for each live view slice i of this cell, Render::set_view, then per
portal compute its screen clip via GetClip (@ 0x005a4320, projects the portal poly to screen,
runs polyClipFinish, honors Sidedness). The decisive branch on the portal's destination
(pc:433651-433701):
GetClip(this, portal.portal_side, portal.portal, &clip_view, &clipNonEmpty, 1);
if (clipNonEmpty != 0) {
if (portal.other_cell_id == 0xffffffff) { // pc:433662 ── EXIT PORTAL ──
if (this->draw_landscape != 0) { // pc:433664 indoor PView built with draw_landscape=1
if (cliplandscape != 0)
Render::copy_view(this /*->outside_view*/, &clip_view, clipNonEmpty); // pc:433674
else /* draw_landscape */
Render::copy_view(this /*->outside_view*/, nullptr, 0);
}
} else if (portal.other_cell_ptr != 0) { // pc:433687 ── INTERIOR PORTAL ──
// OtherPortalClip intersects clip with the neighbour's own portal opening, then
// copies the resulting clip region into the neighbour cell's portal_view (set_view/copy_view)
OtherPortalClip(this, portal, &clip_view, &clipNonEmpty); // pc:433692
// → records the clipped view onto edi_1 (the neighbour's portal_view at +0x134/+0x138)
}
}
This is the load-bearing seam. The exit-portal branch (0xffffffff) is the only place the
outdoors enters the indoor traversal: it accumulates the doorway's screen region into
this->outside_view. The interior branch propagates the clipped region into the neighbour cell so
that cell is later drawn only through the intersection of all portals it was seen through.
3.6 PView::AddViewToPortals @ 0x005a52d0 (pc:433446) — enqueue neighbours
For each portal of the cell whose other_cell_ptr exists and is active:
neighbour = portal.other_cell_ptr;
if (neighbour not yet inited this traversal) {
InitCell(this, neighbour, portal.other_portal_id); // pc:433480 set up neighbour's view slot
InsCellTodoList(this, neighbour, portal.max_indist); // pc:433485 enqueue it
if (portal.flag >= 0) SetOtherSeen(this, cell, portalIdx); // pc:433490 mark exit-seen / matching portal
} else {
AddToCell(this, neighbour, portal.other_portal_id); // pc:433494 merge a new clip slice
}
SetOtherSeen @ 0x005a4e30 records the reciprocal portal as seen (prevents re-entering the cell
you just came from with a redundant slice). InsCellTodoList @ 0x005a4f50 pushes onto the worklist
(growing it as needed).
3.7 The exterior→interior sibling: ConstructView(CBldPortal*, CPolygon*, int, int) @ 0x005a59a0 (pc:433827)
Used by DrawPortal (Section 6.2) when the camera is outside and can see a building door:
// side-test the building portal polygon vs the viewer viewpoint:
d = dot(FrameCurrent->viewer.viewpoint, portal->plane.N) + portal->plane.d;
side = (d < 0.0002) ? NEGATIVE : (|d|<eps ? IN_PLANE : POSITIVE); // pc:433832-433849
if (side matches the portal's required sidedness) {
GetClip(this, side, portal, &clip_view, &arg3, arg4); // pc:433856 clip to the door opening
if (clip non-empty) {
interior = CEnvCell::GetVisible(bldPortal->other_cell_id); // the room behind the door
if (arg5 != 2) DrawPortalPolyInternal(portal, …); // stencil the doorway
ConstructView(this, interior, bldPortal->other_portal_id); // pc:433879 RECURSE into the interior
}
}
So the same ConstructView builds the interior visible set whether entered from inside (the
CEnvCell overload) or from outside through a building portal (the CBldPortal overload). This is
why outside↔inside is seamless.
4. Seal mechanics — PView::DrawCells @ 0x005a4840 (pc:432709), the exact sequence
This is the function the prior studies under-covered. Read this section against the verbatim pseudo-C; it has three sequential per-cell loops plus the landscape-through-door block.
4.1 The landscape-through-door block + conditional Z-clear (pc:432715-432732)
if (this->outside_view.view_count > 0) { // pc:432715 an exit portal was visible
Render::useSunlightSet(1);
Render::PortalList = this; // pc:432718 tell LScape to clip to outside_view
LScape::draw(this->lscape); // pc:432719 DRAW terrain+sky+rain+exterior, CLIPPED
D3DPolyRender::FlushAlphaList(0);
render_device->m_nFrameStamp += 1;
// ── CONDITIONAL Z-ONLY CLEAR ──
bool nothingDrawn;
if (forceClear == 0) {
nothingDrawn = (D3DPolyRender::portalsDrawnCount == 0); // pc:432727
D3DPolyRender::portalsDrawnCount = 0;
}
if (forceClear != 0 || !nothingDrawn)
render_device->vtable->Clear(4, 0x820fc0, 1.0f); // pc:432732 flag 4 == Z-BUFFER ONLY
... (loops below run inside this `if`) ...
}
Critical seal facts:
Clear(4, …)— the4is the depth/Z buffer bit only, NOT color. There is noClear(color)anywhere in this path. The "blue hole" acdream sees is the absence of theLScape::drawstep (the outdoors is never injected), not a stray blue clear.- The clear is conditional on
portalsDrawnCount(andforceClear). It re-establishes a clean depth field for the interior cells after the landscape (which is at far depth) was drawn through the doorway, so interior geometry composites correctly over it. Color is preserved → the terrain pixels in the doorway survive. Render::PortalList = thisbeforeLScape::drawis what clips the entire landscape draw to the union of exit-portal screen regions inoutside_view. Outside the doorway region, the landscape contributes nothing.0x820fc0is the clear-region/rect parameter (the area to clear), not a color.
4.2 Loop 1 — exit-portal stencil masks (pc:432737-432808)
Iterates cell_draw_list from cell_draw_num-1 down to 1 (reverse). For each cell whose
structure->drawing_bsp != 0:
SetCurrentMaterial(render_device, nullptr, 0);
Render::SetSurfaceArray(cell->surfaces);
Render::object_scale = 1; Render::positionPush(3, &cell->pos);
viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1; // -1 == "one default view"
for (view = 0; view < |viewCount|; view++) { // pc:432772
CEnvCell::setup_view(cell, view); // pc:432774 select this clip slice
for (j = 0; j < cell->num_portals; j++) {
if (cell->portals[j].other_cell_id == 0xffffffff) // pc:432785 EXIT portal
D3DPolyRender::DrawPortalPolyInternal(cell->portals[j].portal, 0); // pc:432786 STENCIL the opening
}
}
Render::framePop();
This stencils each exit-portal polygon (per clip slice) so the interior geometry drawn next is
masked to leave the doorway showing the already-drawn landscape. (setup_view re-binds the
per-slice clip region so multi-doorway cells are handled correctly.)
4.3 Loop 2 — draw the closed interior geometry (pc:432815-432866)
Runs unconditionally (outside the outside_view>0 block — i.e., for dungeons too). Reverse
iterate cell_draw_list; for each cell with drawing_bsp != 0:
SetCurrentMaterial(...); Render::SetSurfaceArray(cell->surfaces);
Render::object_scale = 1; Render::positionPush(3, &cell->pos);
viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1;
for (view = 0; view < |viewCount|; view++) {
CEnvCell::setup_view(cell, view); // pc:432852 select clip slice
render_device->vtable->DrawEnvCell(cell); // pc:432853 DRAW floor+walls+CEILING (drawing_bsp)
}
Render::framePop();
DrawEnvCell draws the cell's full closed mesh (floor, four walls, ceiling) from
structure->drawing_bsp, back-face-culled and clipped to the cell's accumulated portal_view
slices. The ceiling is sealed because it is part of the cell mesh — there is no separate cap step.
Portal openings are not filled (Loop 1 stenciled them).
4.4 Loop 3 — per-cell objects/particles, clipped (pc:432870-432882)
for (cell = cell_draw_num-1; cell >= 0; cell--) {
eax = cell_draw_list[cell];
// PortalList = the cell's CURRENT portal_view slot (its accumulated clip region):
Render::PortalList = *(eax->portal_view.data[eax->num_view - 1] ... ); // pc:432877
render_device->vtable->DrawObjCellForDummies(eax); // pc:432878 draw this cell's objects
}
Render::object_scale = 1; Render::useSunlightSet(1); // restore
DrawObjCellForDummies(cell) draws the objects registered in that cell's object_list
(the same list physics enter_cell/leave_cell maintains), with Render::PortalList set to the
cell's clip region — so objects (and their attached particles) are clipped to the portal opening(s)
through which their cell is visible. An object in a cell not in cell_draw_list is never drawn
→ no bleed-through. An object straddling a portal is clipped to the opening.
4.5 Why the seal holds — the four guarantees
- No blue hole: outdoors is drawn first (
LScape::draw, clipped tooutside_view); the only clear is Z-only and conditional; color survives in the doorway. - Sealed ceiling/walls: each visible cell's
drawing_bspis a closed box;DrawEnvCelldraws it whole; portal holes are stencil-masked, not filled. - No outdoor bleed-in: the landscape only paints through exit-portal clip regions; if
outside_view.view_count == 0(dungeon),LScape::drawis never called at all. - No object/particle bleed: objects are drawn per-cell, clipped to that cell's
PortalList, only for cells incell_draw_list.
5. Data structures (annotated, verbatim from acclient.h)
5.1 The cell hierarchy
// acclient.h:30915 — the base cell. ONE graph for physics AND render.
struct CObjCell : SerializeUsingPackDBObj, CPartCell {
LandDefs::WaterType water_type;
Position pos; // cell-to-world frame
unsigned int num_objects;
DArray<CPhysicsObj*> object_list; // RENDER + physics: objects in this cell (Loop 3)
unsigned int num_lights; DArray<LIGHTOBJ const*> light_list;
unsigned int num_shadow_objects;
DArray<CShadowObj*> shadow_object_list; // PHYSICS: collision objects registered here
unsigned int restriction_obj;
ClipPlaneList **clip_planes;
unsigned int num_stabs;
unsigned int *stab_list; // STATIC visibility set (PVS): cell ids this cell can see
int seen_outside; // boolean: this interior can reach the outdoors ★
LongNIValHash<GlobalVoyeurInfo>* voyeur_table;
CLandBlock *myLandBlock_;
};
// acclient.h:31880 — adds a building pointer
struct CSortCell : CObjCell { CBuildingObj* building; };
// acclient.h:31886 — outdoor surface cell of a landblock (8×8 grid → ids 0x01..0x40)
struct CLandCell : CSortCell { CPolygon** polygons; BoundingType in_view; };
// acclient.h:32072 — interior cell (building room OR dungeon room; id >= 0x100)
struct CEnvCell : CObjCell {
unsigned int num_surfaces; CSurface** surfaces; // material/surface array (RENDER)
CCellStruct *structure; // geometry + 3 BSP trees (see 5.3)
CEnvironment *env;
unsigned int num_portals; CCellPortal* portals; // portal graph edges
unsigned int num_static_objects;
IDClass* static_object_ids; Frame* static_object_frames; CPhysicsObj** static_objects;
RGBColor *light_array; int incell_timestamp;
MeshBuffer *constructed_mesh; int use_built_mesh; unsigned int m_current_render_frame_num;
unsigned int num_view; // RENDER: # of active per-portal view slots
DArray<portal_view_type*> portal_view; // RENDER: per-portal clip state (see 5.4) ★
};
seen_outside is the dat flag EnvCellFlags.SeenOutside = 0x1 (ACViewer
ACE.Entity/Enum/EnvCellFlags.cs:7). Confirmed verbatim at acclient.h:30929.
5.2 The portals
// acclient.h:32300 — interior↔interior (room↔room) AND interior→exterior
struct CCellPortal {
unsigned int other_cell_id; // 0xFFFFFFFF (low 0xFFFF) ⇒ EXIT PORTAL (opens to the outdoors) ★
CEnvCell *other_cell_ptr; // cached resolved neighbour (or null until GetVisible)
CPolygon *portal; // the portal polygon (its plane = the doorway plane)
int portal_side; // which half-space is "inside" this cell
int other_portal_id; // index of the matching portal in the neighbour
int exact_match;
};
// acclient.h:32094 — outdoor landblock → building interior (the door from the street)
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; // PVS of interior cells visible through this door
float sidedness;
};
Held by CBuildingObj : CPhysicsObj { num_portals; CBldPortal** portals; num_leaves; CPartCell** leaf_cells; ... } (acclient.h:31908), which a CLandCell/CSortCell references.
5.3 Per-cell geometry: CCellStruct (acclient.h:32275) — THREE BSP trees
struct CCellStruct {
... vertex_array; num_portals; CPolygon** portals; surface_strips; polygons;
BSPTree* drawing_bsp; // RENDER — back-face order the closed cell mesh (DrawEnvCell)
... physics_polygons;
BSPTree* physics_bsp; // PHYSICS — collide the sphere against cell walls/floor
BSPTree* cell_bsp; // CONTAINMENT — point/sphere-in-cell tests (point_in_cell, membership)
};
A dungeon cell and a building-interior cell use the same struct; only their portal topology and
seen_outside differ. The closed mesh (with ceiling) is authored in the dat — there is no
ceiling-cap code path.
5.4 The render view state — portal_view_type (acclient.h:32346) + the watermark
struct portal_view_type { // one slot per active view of a CEnvCell (CEnvCell.portal_view[])
DArray<portal_info> portal; // per-portal { int seen; int inflag; } (acclient.h:32459)
view_type view; // the screen-clip geometry (poly+vertex)
float max_indist;
unsigned int view_count; // how many view slices this cell currently has
int cell_view_done;
int view_timestamp;
int update_count; // ★ WATERMARK: slices [update_count..view_count) are "new"
};
struct view_type { unsigned vertex_count_total; DArray<view_poly> poly; DArray<view_vertex> vertex; };
struct view_poly { int vertex_count; int vertex_index; float xmin, xmax, ymin, ymax; }; // 2D screen clip rect/poly
The update_count watermark — how it bounds the BFS without a fixed cap (issue #102):
When a cell is first reached, InitCell sets update_count = view_count (the slices it has).
When the cell is reached again through another portal, AddViewToPortals → AddToCell only
processes slices from update_count up to the (now larger) view_count — i.e., the genuinely new
clip regions — then advances update_count. A cell therefore expands each distinct view slice
exactly once; the traversal terminates when no portal produces a new slice. master_timestamp
(bumped per ConstructView) + view_timestamp/cell_view_done are the per-traversal cycle guards.
This is the retail replacement for acdream's PortalVisibilityBuilder.MaxReprocessPerCell = 4
and the correct fix for dungeon PVS blowup (#95): the watermark naturally converges; a fixed cap
either truncates (missing cells) or never converges (blowup).
5.5 The traversal owner — PView (acclient.h:45934)
struct PView {
portal_view_type outside_view; // ★ accumulated clip region of the outdoors seen thru exit portals
int draw_landscape; // 1 for indoor_pview ⇒ exit portals feed outside_view (ClipPortals)
CBldPortal **outdoor_portal_list; // building doors the camera can see (used by DrawPortal)
DArray<CEnvCell*> cell_draw_list; // ★ ordered visible cells (the BFS output)
unsigned int cell_draw_num;
DArray<CellListType*> cell_todo_list; // the worklist
unsigned int cell_todo_num;
LScape *lscape; // the landscape to draw through doorways
};
5.6 The physics-membership structs (summary — full detail in the studies)
// acclient.h:32625 — the per-transition working state
struct SPHEREPATH {
... CObjCell* begin_cell; Position* begin_pos; Position* end_pos;
CObjCell* curr_cell; Position curr_pos; // ★ ACCEPTED membership (the answer)
... CObjCell* check_cell; Position check_pos; // candidate this sub-step
SPHEREPATH::InsertType insert_type;
CObjCell* backup_cell; ...
int hits_interior_cell; int cell_array_valid; ...
};
// acclient.h:31574 — the COLLISION candidate set (distinct from curr_cell)
struct CELLARRAY { int added_outside; int do_not_load_cells; unsigned int num_cells;
DArray<CELLINFO> cells; }; // CELLINFO = { uint cell_id; CObjCell* cell; }
6. Dungeons, outside-looking-in, object/particle clipping
6.1 Dungeons — emergent, no flag
- Data: a dungeon landblock has terrain heights all 0, ≥1 EnvCell, and no buildings (ACE's
IsDungeonheuristic,references/ACE/.../Landblock.cs:575— a server heuristic; the client needs no such flag). Its EnvCells haveseen_outside == 0and no exit portals. - Movement: identical to a building interior —
transitionadvancescurr_cellacrossCCellPortals;find_transit_cells' exit-portal flag (var_44) never fires (no exit portals), soadd_all_outside_cellsis never called → outdoor cells never enter the candidate set. - Loading:
CEnvCell::grab_visible_cells @ 0x0052e220(pc:311878) — adds self + everystab_listcell to the visible table, thenif (seen_outside == 0) return;(pc:311893 — dungeon stops here, never touches the landscape); aseen_outsidecell tail-callsLScape::grab_visible_cells. This is the exact load-time decision "stream the outdoor world or not." - Render:
DrawInsideruns;ConstructViewfloods the dungeon cells; no exit portal is ever reached, sooutside_view.view_countstays 0;DrawCells' landscape block (§4.1) is skipped; Loops 2+3 still draw the closed cells + their objects. Result: sealed dungeon, no terrain, no sky — by construction, same path as a cottage.
6.2 Outside-looking-in — a building interior seen through its door from the street
Driven by the outdoor render (LScape::draw), which calls PView::DrawPortal @ 0x005a5ab0
(pc:433895) for each building door the camera can see:
Render::m_pRenderer->polyListFinishInternal(); ACRender::backup_curr_state();
bldPortal = this->outdoor_portal_list[arg2->portal_index]; // the building's door
portal = arg2->portal; // its polygon
PView::add_views(this, bldPortal->num_stabs, bldPortal->stab_list); // seed interior PVS
result = ConstructView(this, bldPortal, portal, arg3, arg4); // pc:433910 build interior visible set thru door
if (result == 0) { // door not actually visible
if (arg4 == 3) DrawPortalPolyInternal(portal, 0); // just stencil the closed door
ACRender::restore_curr_state();
} else {
if (arg4 != 1) PView::DrawCells(this, …); // pc:433924 DRAW the interior through the door
ACRender::restore_curr_state();
Render::positionPush(3, CBuildingObj::curr_pos); Render::obj_view_set();
}
PView::remove_views(this, bldPortal->num_stabs, bldPortal->stab_list);
So the same ConstructView + DrawCells that render an interior from inside also render it
from outside through the door — entered via the CBldPortal overload of ConstructView
(§3.7) which side-tests the door plane, clips to the opening, and recurses into the room. The
outdoor_pview instance is used here (vs indoor_pview for DrawInside). This is the path
acdream is missing ("outside-looking-in shows no interior" residual, spec §1a).
6.3 Object / particle clipping to the visible cell set
- Membership: objects live in a cell's
object_list(and collision objects inshadow_object_list), maintained by physicsenter_cell @ 0x00510ed0/leave_cell @ 0x00510f50— the same graph render reads. A particle attached to an object inherits the object's cell. - Draw + clip (indoor): Loop 3 of
DrawCells(§4.4) iterates onlycell_draw_list, draws each cell'sobject_listviaDrawObjCellForDummies, withRender::PortalListset to that cell's clip region. Non-visible cells' objects are never iterated → no bleed. - Child-cell resolution for entities:
CEnvCell::find_visible_child_cell @ 0x0052dc50(pc:311397) — given a point, returns the exact child cell containing it:
Used for the 3rd-person camera offset (which child cell is the eye in?) and for placing entities/particles into the right cell via the graph/BSP, never an AABB. This is the retail-faithful replacement for acdream'sif (point_in_cell(this, p)) return this; // pc:311402 if (arg3 == 0) { // search via portals: for (portal in this->portals) { n = CCellPortal::GetOtherCell(portal); if (n && n->point_in_cell(p)) return n; // pc:311424 } return 0; } else { // search via stab_list (PVS): for (id in this->stab_list) { c = CEnvCell::visible_cell_table[id]; if (c && c->point_in_cell(p)) return c; // pc:311456 } }CellVisibility.FindCameraCellAABB + grace-frame resolver.
7. PORTING CHECKLIST — the spine of the redesign
Ordered list of every behavior a faithful C# port must implement to get a fully-sealed outdoor+indoor+dungeon render with no bleed, no flaps, no transparent walls, no blue hole. Cross-referenced to the Phase W staged plan; each behavior cites its retail anchor.
CL-A. Membership foundation (prerequisite — physics owns the cell)
(Detail in the four studies + spec §1/§1a; summarized here because render roots on it.)
- A1.
ResolveWithTransitionreturns the sweptsp.CurCellId(mirrorSetPositionInternal @ 0x00515330readingsphere_path.curr_cell), not a staticResolveCellId(origin,…). DemoteResolveCellIdto seed-only (spawn/teleport/server-set). - A2. Replace
FindEnvCollisions' early static re-derive (TransitionTypes.cs:1947→1949) with a retail directed exit-portal crossing (CEnvCell::find_transit_cells @ 0x0052c820exit-portal path) — become outdoor by crossing the doorway polygon, not by re-resolving XYZ. - A3. Port the
find_cell_listinterior-wins pick (@ 0x0052b4e0, pc:308814-308819) +do_not_load_cellsprune (pc:308829-308867) intoCellTransit; re-gateFindTransitCellsSphere's unconditionalexitOutside=true(CellTransit.cs:95-123). - A4. Commit-on-difference: write
CellGraph.CurrCellonce, fire a "cell changed" event only when it differs (mirrorchange_cell @ 0x00513390, theif (this->cell != curr_cell)guard).
CL-B. Render root unification (Stage 3)
- B1. Port the single per-frame decision (
RenderNormalMode @ 0x00453aa0): viewer cell is an outdoor landcell (id&0xFFFF < 0x100,is_player_outside @ 0x00451e80) → outdoor path; else → indoorDrawInside. Remove theACDREAM_A8_INDOOR_BRANCHtwo-pipe split. - B2. Root render visibility at the physics
CellGraph.CurrCell(the U.4c flap fix), not an independent AABBFindCameraCell. Delete the 3-frame grace-frame hack. - B3. 3rd-person camera offset resolves its child cell via
CEnvCell::find_visible_child_cell @ 0x0052dc50(graph/BSP), rooted at the player cell — never an AABB reclassification. (Eye drives projection; player cell drives the visibility root.) - B4. Port the landscape keep/release policy (
CellManager::ChangePosition @ 0x004559b0, pc:94601-94682): keep landscape loaded + sunlight/outdoor-ambient live iff the current cell is a landcell ORseen_outside; elseLScape::release_all+ flat indoor ambient. - B5. Port
CEnvCell::grab_visible_cells @ 0x0052e220(pc:311878): add self +stab_listto the visible set; load the landscape iffseen_outside(the dungeon gate, pc:311893). - B6. Pre-position the through-door terrain via
Position::get_outside_cell_id @ 0x004527b0+LScape::update_viewpointbefore the indoor draw (whenseen_outside).
CL-C. PView traversal (Stage 4, the big one)
- C1. Port the BFS
PView::ConstructView(CEnvCell*) @ 0x005a57b0: resetoutside_view, bumpmaster_timestamp,InitCell(root), push root, then pop-and-expand intocell_draw_listviaClipPortals+AddViewToPortalsuntil the worklist drains. - C2. Port
PView::InitCell @ 0x005a4b70: per-portal sidedness vs the viewer viewpoint →seen/inflag; computemax_indist; setupdate_count = view_count. - C3. Port
PView::ClipPortals @ 0x005a5520with both branches: exit portal (other_cell_id == 0xffffffff) →copy_viewintooutside_view(gated bydraw_landscape); interior portal →OtherPortalClip→ propagate clipped region into the neighbour'sportal_view. - C4. Port
PView::AddViewToPortals @ 0x005a52d0: enqueue uninited neighbours (InitCell+InsCellTodoList+SetOtherSeen); merge a new slice into already-inited neighbours (AddToCell @ 0x005a4d90). - C5. Implement the
update_countwatermark convergence (per-cell, slices[update_count..view_count)processed once) — delete the fixedMaxReprocessPerCellcap (closes #102; correct dungeon-PVS fix for #95). - C6. Port
PView::GetClip @ 0x005a4320(project portal poly → screen,polyClipFinish, honorSidedness) producing 2Dview_polyclip rects/polys.
CL-D. Seal mechanics in DrawCells (Stage 4)
- D1. When
outside_view.view_count > 0: setPortalList = this,LScape::draw(lscape)(terrain+sky+rain clipped to the doorway) FIRST (DrawCells @ 0x005a4840, pc:432715-432719). - D2. Implement the conditional Z-only clear (
Clear(4, …), pc:432731-432732): depth bit only, NOT color; conditional onportalsDrawnCount/forceClear. NeverClear(color)in the indoor path — that is the blue-hole bug. - D3. Loop 1 — per visible cell, per view slice (
setup_view),DrawPortalPolyInternalevery exit portal (other_cell_id == 0xffffffff) to stencil the openings (pc:432785-432786). - D4. Loop 2 — per visible cell, per view slice,
DrawEnvCellthe closedstructure->drawing_bsp(floor+walls+ceiling). Runs for dungeons too (unconditional). No ceiling-cap step — the mesh is closed (pc:432852-432853). - D5. Loop 3 — per visible cell, set
PortalListto the cell's clip region, thenDrawObjCellForDummies(the cell'sobject_list) — objects/particles clipped to the doorway (pc:432876-432878). - D6. Renderer self-contained GL state: the indoor pass must SET every GL state it depends
on (view-proj, BLEND, DepthMask, Cull, FrontFace, A2C) — never inherit (memory
render-self-contained-gl-state;EnvCellRendererhit this 3× in U.4).
CL-E. Outside-looking-in (Stage 4/5)
- E1. Port
PView::DrawPortal @ 0x005a5ab0on the outdoor path: for each visible building door (outdoor_portal_list),add_views(stab_list),ConstructView(CBldPortal,polygon), thenDrawCellsthe interior through the door's clip; if the door isn't actually visible, justDrawPortalPolyInternal(stencil the closed door). - E2. Port the
ConstructView(CBldPortal*) @ 0x005a59a0exterior→interior recursion: side-test the door plane vs the viewer,GetClipto the opening,GetVisible(other_cell_id), optionalDrawPortalPolyInternal, then recurseConstructView(interior, other_portal_id). - E3. Use a separate
outdoor_pviewinstance for E1/E2 (vsindoor_pviewforDrawInside), matchingRenderDeviceD3D::indoor_pview/outdoor_pview_1.
CL-F. Entity / particle cell clipping (Stage 5)
- F1. Entities/particles are placed into cells via the physics graph (
object_list), resolved withfind_visible_child_cell @ 0x0052dc50— never an AABB. - F2. Entities/particles draw only for cells in
cell_draw_list, clipped to the cell'sPortalList(Loop 3). Non-visible-cell entities are not drawn → no NPC/door/smoke bleed-through. - F3. An entity straddling a portal is clipped to the portal opening (inherits the cell's clip region), not drawn full-screen.
CL-G. Conformance / acceptance gates
- G1. Cottage (
seen_outside): flicker gone (CL-A); interior sealed; sky/rain visible through the door; no blue hole; no transparent walls; no bleed-through. - G2. Dungeon (
seen_outside == 0everywhere): sealed; no terrain/sky;outside_view.view_count == 0; traversal converges (watermark) without blowup (#95). - G3. Outside-looking-in: standing in the street facing an open cottage door, the interior (walls/floor/objects) renders through the doorway (CL-E).
- G4. Headless asserts: doorway
outside_viewnon-empty +LScape::drawinvoked; sealed-cellaroutside_viewempty +LScape::drawNOT invoked; PVS root id == physicsCurrCell.Idevery frame; a cell receiving two clip slices is processed once per slice.
Appendix — primary decomp address index (all read/verified this session)
Top-level / landscape policy
SmartBox::RenderNormalMode 0x00453aa0 pc:92635 (binary decision; DrawInside vs LScape::draw)
SmartBox::is_player_outside 0x00451e80 pc:90996 (low-word objcell_id < 0x100)
Position::get_outside_cell_id 0x004527b0 pc:91552 (interior pos → outdoor landcell id)
CellManager::ChangePosition 0x004559b0 pc:94601 (keep/release landscape on seen_outside)
CEnvCell::grab_visible_cells 0x0052e220 pc:311878 (self+stab; landscape iff seen_outside @311893)
RenderDeviceD3D::DrawInside 0x0059f0d0 pc:427843 (→ PView::DrawInside(indoor_pview))
PView traversal + seal
PView::DrawInside 0x005a5860 pc:433793
PView::ConstructView(CEnvCell) 0x005a57b0 pc:433750 (the BFS worklist)
PView::ConstructView(CBldPortal) 0x005a59a0 pc:433827 (exterior→interior recursion)
PView::InitCell 0x005a4b70 pc:432896 (per-portal sidedness; update_count=view_count)
PView::AddToCell 0x005a4d90 pc:433050 (incremental new-slice merge)
PView::AddViewToPortals 0x005a52d0 pc:433446 (enqueue neighbours / SetOtherSeen)
PView::ClipPortals 0x005a5520 pc:433572 (exit→outside_view @433662; interior→OtherPortalClip)
PView::OtherPortalClip 0x005a5400 pc:433524
PView::GetClip 0x005a4320 pc:432344 (portal poly → screen clip)
PView::SetOtherSeen 0x005a4e30 pc:433089
PView::InsCellTodoList 0x005a4f50 pc:433183
PView::add_views / remove_views 0x005a5210 pc:433382
PView::DrawCells 0x005a4840 pc:432709 (LScape-thru-door + Z-clear + 3 loops)
PView::DrawPortal 0x005a5ab0 pc:433895 (outside-looking-in entry)
LScape::draw 0x00506330 (terrain+sky; clipped via Render::PortalList)
CEnvCell::find_visible_child_cell 0x0052dc50 pc:311397 (point → child cell via portals/stab_list)
Membership (summary; see studies)
CTransition::validate_transition 0x0050aa70 pc:272547
CTransition::check_other_cells 0x0050ae50 pc:272717
CObjCell::find_cell_list 0x0052b4e0 pc:308742
CPhysicsObj::SetPositionInternal 0x00515330 pc:283399
CPhysicsObj::change_cell 0x00513390 pc:281192
CEnvCell::find_transit_cells 0x0052c820 pc:309968
CLandCell::add_all_outside_cells 0x00533630 pc:317499
CEnvCell::check_building_transit 0x0052c5d0 pc:309827
CObjCell::GetVisible 0x0052ad40 pc:308209 (≥0x100→CEnvCell::GetVisible else CLandCell)
CEnvCell::GetVisible 0x0052dc10 pc:311378
Structs (acclient.h)
CObjCell 30915 (object_list 30920; shadow_object_list 30924; num_stabs/stab_list 30927-28; seen_outside 30929)
CEnvCell 32072 (structure 32076; portals 32079; surfaces 32075; num_view/portal_view 32089-90)
CLandCell 31886 | CSortCell 31880 | CBuildingObj 31908
CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 (drawing_bsp/physics_bsp/cell_bsp)
portal_view_type 32346 (view_count, cell_view_done, view_timestamp, update_count) | view_type 32338
view_poly 32465 | portal_info 32459 (seen, inflag) | PView 45934 (outside_view, draw_landscape,
outdoor_portal_list, cell_draw_list, cell_todo_list, lscape)
SPHEREPATH 32625 | CELLARRAY 31574 | CELLINFO 31925
Reference cross-checks
ACE Transition.cs:984 / PhysicsObj.cs:1171 / ObjCell.cs:335 / EnvCell.cs:311 / CellArray.cs:17
ACE Landblock.cs:575 (IsDungeon, server heuristic — client needs no flag)
ACViewer EnvCellFlags.cs:7 (SeenOutside=0x1); Buffer.cs (brute-force draw, NO PView — divergent)
WorldBuilder PortalService / VisibilityManager.RenderInsideOut (flat stencil — Silk.NET base, NOT the algorithm)
Divergence note (do not copy the references' render model). ACViewer brute-force draws all loaded EnvCells with a
DungeonModecull toggle; WorldBuilder uses a flatRenderInsideOutstencil pass. Neither implements retail's portal-clippedPView/ landscape-through-door. For Sections 2–7 the decomp is the sole authority and it wins. WorldBuilder's classes are a useful Silk.NET implementation base (buffer management, shader plumbing) but the algorithm isPViewas documented above.