acdream/docs/research/2026-06-02-retail-render-pipeline-full-reference.md
Erik 21bf97ed35 docs(render): REOPEN the render half — full retail-faithful redesign dossier (handoff + huge plan + 3 research docs)
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>
2026-06-02 18:28:01 +02:00

48 KiB
Raw Blame History

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 from docs/research/named-retail/acclient_2013_pseudo_c.txt and verbatim structs from docs/research/named-retail/acclient.h. Citations are Class::method @ 0xADDR (pc:LINE) for decomp and acclient.h:LINE for 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 27) is the new, deep material.


1. Executive summary — the ONE retail pipeline (≤12 bullets)

  1. One cell graph, one membership answer, render obeys it. Physics tracks the player's cell as SPHEREPATH::curr_cell carried through the collision sweep; the camera tracks its own viewer_cell via a second (spring-arm) transition. Both resolve through the same CObjCell::GetVisible(objcell_id) graph. There is no separate render cell system, no static "re-derive the cell from XYZ", and no "underground" boolean.

  2. 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, only DrawInside runs.

  3. DrawInside (PView) is one portal-flood traversal (PView::DrawInside @ 0x005a5860ConstructView @ 0x005a57b0DrawCells @ 0x005a4840). It produces an ordered cell_draw_list (visible EnvCells) plus per-cell screen clip regions (portal_view) and one accumulated outside_view (the outdoors seen through exit portals).

  4. The landscape is pulled INTO the indoor traversal through exit portals. A portal with other_cell_id == 0xffffffff is an exit portal. ClipPortals (@ 0x005a5520, pc:433662) copies its screen clip region into this->outside_view (gated by draw_landscape). This is the seam that makes the outside visible through a doorway.

  5. The seal sequence in DrawCells (pc:432715+), when outside_view.view_count > 0: (a) LScape::draw(lscape) with Render::PortalList = this → terrain+sky+rain+exterior, clipped to the doorway; (b) a conditional Z-only clear Clear(4, …) (flag 4 = Z buffer, NOT color; conditional on portalsDrawnCount); (c) per-cell exit-portal stencil via DrawPortalPolyInternal; (d) per-cell DrawEnvCell (the closed interior geometry); (e) per-cell DrawObjCellForDummies with PortalList set to that cell's view (objects).

  6. 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.

  7. Ceilings/walls/floors are sealed by the dat geometry. Each EnvCell's drawing_bsp is a closed box (floor + walls + ceiling) with holes only where CCellPortals exist. There is no "cap the ceiling" step; DrawCells draws each visible cell's drawing_bsp. Portal openings are masked, not filled.

  8. Visibility IS the cull. Only cells reached by the portal BFS are in cell_draw_list; only their objects (object_list, drawn per-cell with PortalList set) are drawn. An object/ particle in a non-visible cell is never iterated → no wall bleed-through. Membership comes from the same physics enter_cell/leave_cell graph.

  9. Outside-looking-in is the mirror image, same machinery. While drawing the landscape, when the camera can see a building's door, PView::DrawPortal @ 0x005a5ab0 runs ConstructView(CBldPortal, polygon, …) @ 0x005a59a0 — a viewer-vs-portal-plane side test, a GetClip, then it recurses ConstructView(interior_cell, other_portal_id) and DrawCells the interior through the door's clip region. Outside↔inside is one recursive portal-clipped traversal over the shared graph.

  10. 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 == 0 and no exit portals. So outside_view.view_count stays 0, LScape::draw is never reached, and there is no terrain/ sky — automatically. Same DrawInside path as a cottage interior.

  11. Terrain/sky/landscape state keys off seen_outside. CellManager::ChangePosition @ 0x004559b0 keeps the landscape loaded + sunlight/outdoor-ambient live iff the current cell is a landcell or CObjCell::seen_outside is set; otherwise it release_alls the landscape and uses flat indoor ambient. CEnvCell::grab_visible_cells @ 0x0052e220 loads the landscape iff seen_outside.

  12. BFS convergence is watermark-bounded, not capped. ConstructView uses a per-cell portal_view_type.update_count watermark + a cell_todo_list worklist; a cell can be re-processed only for genuinely new view slices (update_count → view_count). This is the retail replacement for acdream's fixed MaxReprocessPerCell cap (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 is LScape::draw. When inside, the only draw is DrawInside — the landscape, if shown, is drawn inside DrawInside/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 is outside_view.view_count > 0 inside DrawCells.
  • The predicate maps to SmartBox::is_player_outside @ 0x00451e80 (pc:90996):
    int 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)
    }
    
    Outdoor landcell ids are 0x0001..0x0040; EnvCell ids are >= 0x0100. This low-word test is the type discriminator used everywhere (find_cell_list pc:308753; GetVisible pc: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>0LScape::draw clipped to doorway).
EnvCell (dungeon / sealed room) 0 DrawInside No (no exit portal reachable → outside_view==0LScape::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_count empty). 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-portal seen/inflag (portal_info { int seen; int inflag; }, acclient.h:32459) according to portal_side — i.e. which portals face the viewer and can be seen through. Compute max_indist and set update_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, …) — the 4 is the depth/Z buffer bit only, NOT color. There is no Clear(color) anywhere in this path. The "blue hole" acdream sees is the absence of the LScape::draw step (the outdoors is never injected), not a stray blue clear.
  • The clear is conditional on portalsDrawnCount (and forceClear). 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 = this before LScape::draw is what clips the entire landscape draw to the union of exit-portal screen regions in outside_view. Outside the doorway region, the landscape contributes nothing.
  • 0x820fc0 is 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

  1. No blue hole: outdoors is drawn first (LScape::draw, clipped to outside_view); the only clear is Z-only and conditional; color survives in the doorway.
  2. Sealed ceiling/walls: each visible cell's drawing_bsp is a closed box; DrawEnvCell draws it whole; portal holes are stencil-masked, not filled.
  3. No outdoor bleed-in: the landscape only paints through exit-portal clip regions; if outside_view.view_count == 0 (dungeon), LScape::draw is never called at all.
  4. No object/particle bleed: objects are drawn per-cell, clipped to that cell's PortalList, only for cells in cell_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 IsDungeon heuristic, references/ACE/.../Landblock.cs:575 — a server heuristic; the client needs no such flag). Its EnvCells have seen_outside == 0 and no exit portals.
  • Movement: identical to a building interior — transition advances curr_cell across CCellPortals; find_transit_cells' exit-portal flag (var_44) never fires (no exit portals), so add_all_outside_cells is never called → outdoor cells never enter the candidate set.
  • Loading: CEnvCell::grab_visible_cells @ 0x0052e220 (pc:311878) — adds self + every stab_list cell to the visible table, then if (seen_outside == 0) return; (pc:311893 — dungeon stops here, never touches the landscape); a seen_outside cell tail-calls LScape::grab_visible_cells. This is the exact load-time decision "stream the outdoor world or not."
  • Render: DrawInside runs; ConstructView floods the dungeon cells; no exit portal is ever reached, so outside_view.view_count stays 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 in shadow_object_list), maintained by physics enter_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 only cell_draw_list, draws each cell's object_list via DrawObjCellForDummies, with Render::PortalList set 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:
    if (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
        }
    }
    
    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's CellVisibility.FindCameraCell AABB + 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. ResolveWithTransition returns the swept sp.CurCellId (mirror SetPositionInternal @ 0x00515330 reading sphere_path.curr_cell), not a static ResolveCellId(origin,…). Demote ResolveCellId to 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 @ 0x0052c820 exit-portal path) — become outdoor by crossing the doorway polygon, not by re-resolving XYZ.
  • A3. Port the find_cell_list interior-wins pick (@ 0x0052b4e0, pc:308814-308819) + do_not_load_cells prune (pc:308829-308867) into CellTransit; re-gate FindTransitCellsSphere's unconditional exitOutside=true (CellTransit.cs:95-123).
  • A4. Commit-on-difference: write CellGraph.CurrCell once, fire a "cell changed" event only when it differs (mirror change_cell @ 0x00513390, the if (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 → indoor DrawInside. Remove the ACDREAM_A8_INDOOR_BRANCH two-pipe split.
  • B2. Root render visibility at the physics CellGraph.CurrCell (the U.4c flap fix), not an independent AABB FindCameraCell. 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 OR seen_outside; else LScape::release_all + flat indoor ambient.
  • B5. Port CEnvCell::grab_visible_cells @ 0x0052e220 (pc:311878): add self + stab_list to the visible set; load the landscape iff seen_outside (the dungeon gate, pc:311893).
  • B6. Pre-position the through-door terrain via Position::get_outside_cell_id @ 0x004527b0 + LScape::update_viewpoint before the indoor draw (when seen_outside).

CL-C. PView traversal (Stage 4, the big one)

  • C1. Port the BFS PView::ConstructView(CEnvCell*) @ 0x005a57b0: reset outside_view, bump master_timestamp, InitCell(root), push root, then pop-and-expand into cell_draw_list via ClipPortals + AddViewToPortals until the worklist drains.
  • C2. Port PView::InitCell @ 0x005a4b70: per-portal sidedness vs the viewer viewpointseen/inflag; compute max_indist; set update_count = view_count.
  • C3. Port PView::ClipPortals @ 0x005a5520 with both branches: exit portal (other_cell_id == 0xffffffff) → copy_view into outside_view (gated by draw_landscape); interior portal → OtherPortalClip → propagate clipped region into the neighbour's portal_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_count watermark convergence (per-cell, slices [update_count..view_count) processed once) — delete the fixed MaxReprocessPerCell cap (closes #102; correct dungeon-PVS fix for #95).
  • C6. Port PView::GetClip @ 0x005a4320 (project portal poly → screen, polyClipFinish, honor Sidedness) producing 2D view_poly clip rects/polys.

CL-D. Seal mechanics in DrawCells (Stage 4)

  • D1. When outside_view.view_count > 0: set PortalList = 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 on portalsDrawnCount/forceClear. Never Clear(color) in the indoor path — that is the blue-hole bug.
  • D3. Loop 1 — per visible cell, per view slice (setup_view), DrawPortalPolyInternal every exit portal (other_cell_id == 0xffffffff) to stencil the openings (pc:432785-432786).
  • D4. Loop 2 — per visible cell, per view slice, DrawEnvCell the closed structure->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 PortalList to the cell's clip region, then DrawObjCellForDummies (the cell's object_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; EnvCellRenderer hit this 3× in U.4).

CL-E. Outside-looking-in (Stage 4/5)

  • E1. Port PView::DrawPortal @ 0x005a5ab0 on the outdoor path: for each visible building door (outdoor_portal_list), add_views(stab_list), ConstructView(CBldPortal,polygon), then DrawCells the interior through the door's clip; if the door isn't actually visible, just DrawPortalPolyInternal (stencil the closed door).
  • E2. Port the ConstructView(CBldPortal*) @ 0x005a59a0 exterior→interior recursion: side-test the door plane vs the viewer, GetClip to the opening, GetVisible(other_cell_id), optional DrawPortalPolyInternal, then recurse ConstructView(interior, other_portal_id).
  • E3. Use a separate outdoor_pview instance for E1/E2 (vs indoor_pview for DrawInside), matching RenderDeviceD3D::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 with find_visible_child_cell @ 0x0052dc50 — never an AABB.
  • F2. Entities/particles draw only for cells in cell_draw_list, clipped to the cell's PortalList (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 == 0 everywhere): 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_view non-empty + LScape::draw invoked; sealed-cellar outside_view empty + LScape::draw NOT invoked; PVS root id == physics CurrCell.Id every 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 DungeonMode cull toggle; WorldBuilder uses a flat RenderInsideOut stencil pass. Neither implements retail's portal-clipped PView / landscape-through-door. For Sections 27 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 is PView as documented above.