# 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 2–7) 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 @ 0x005a5860` → `ConstructView @ 0x005a57b0` → `DrawCells @ 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 `CCellPortal`s 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_all`s 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: ```c 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)**: ```c 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>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 ```c 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) ```c 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: ```c 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): ```c 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: ```c 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: ```c // 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|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) ```c 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`: ```c 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`: ```c 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) ```c 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 ```c // 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 object_list; // RENDER + physics: objects in this cell (Loop 3) unsigned int num_lights; DArray light_list; unsigned int num_shadow_objects; DArray 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* 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; // 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 ```c // 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 ```c 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 ```c struct portal_view_type { // one slot per active view of a CEnvCell (CEnvCell.portal_view[]) DArray 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 poly; DArray 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) ```c 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 cell_draw_list; // ★ ordered visible cells (the BFS output) unsigned int cell_draw_num; DArray 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) ```c // 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 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 `CCellPortal`s; `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: ```c 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: ```c 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 viewpoint** → `seen`/`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 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* > is `PView` as documented above.