Deliverable 1: docs/research/2026-06-11-building-render-acdream-vs-retail- comparison.md - the acdream-vs-retail architecture comparison synthesized from two ultracode mapping fan-outs (11/12 areas, ~90 agents, every retail claim Ghidra/pc-cited, every acdream claim file:line, 40/76 divergences adversarially verified so far; raw per-area evidence committed under docs/research/2026-06-11-holistic-map/). Headline findings: (1) retail flattens GfxObjs/cells at load exactly like us (ConstructMesh + RemoveNonPortalNodes) - the MDI pipeline survives; (2) the phantom/door mechanism is the skipNoTexture draw-time surface gate (dat-confirmed); (3) retail never geometrically clips world geometry - aperture exactness is a DEPTH discipline (punch maxZ1 / seal maxZ2 / gated clear + far-to-near whole-mesh draws) - reframes #114; (4) flood admission is already faithful, the trigger/depth/multi-view/cone-culling layers are missing; (5) #115 root cause verified (boom damping severed from the published collided viewer); collision A6.P4 design verified with corrections (signed other_portal_id >= 0 gate). Deliverable 2: docs/plans/2026-06-11-building-render-port-plan.md - the phased port plan (BR-1 surface gate, BR-2 depth punch/seal, BR-3 delete the shell chop, BR-4 draw-driven floods, BR-5 viewconeCheck, BR-6 one gate, BR-7 collision A6.P4, BR-8 camera/lighting/LOD) with per-phase acceptance criteria, bug closures, keep-list, and a playable-after-every- phase migration order. AWAITING USER APPROVAL - no implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
36 KiB
AREA 3 — Interior cell rendering and the draw-side portal clip (#114)
RETAIL
THE DATA STRUCTURES. A "portal view" is NOT a set of clip planes handed to the rasterizer — it is a 2D screen polygon whose edges each carry a 3D plane through the eye, stored per cell. portal_view_type (acclient.h:32345-32355) = { DArray<portal_info> portal; view_type view; float max_indist; uint view_count; int cell_view_done; int view_timestamp; int update_count }. view_type (acclient.h:32337-32343) = { vertex_count_total; DArray<view_poly>; DArray<view_vertex> } — note the PLURAL: one cell accumulates a LIST of view polygons. view_poly (acclient.h:32465-32473) = { vertex_count, vertex_index, xmin/xmax/ymin/ymax } (a slice into the vertex array + 2D screen bbox). view_vertex (acclient.h:32483-32487) = { Vec2D pt; Plane plane } — a screen point PLUS the 3D eye-edge plane used for object culling. portal_info (acclient.h:32458-32462) = { int seen; int inflag }. CCellPortal (acclient.h:32300-32308) = { other_cell_id, other_cell_ptr, CPolygon* portal, portal_side, other_portal_id, exact_match }. Crucially, CCellStruct::UnPack (Ghidra 0x00533d00) shows portals[i] = polygons + portal_poly_id — portal-aperture polygons are ordinary entries in the SAME drawn-polygon array.
VIEW CONSTRUCTION (the flood). PView::DrawInside (Ghidra 0x005a5860, pc:433793): curr_view_push(cell), add_views over the cell's stab list, positionPush(cell), then Render::copy_view(cell.top_view, nullptr, 4) — a NULL source installs the FULL-SCREEN 4-vertex viewport quad as the root view (Ghidra 0x0054dfc0, also pc:345574) — then ConstructView(cell, 0xffff) and DrawCells(this, 0). ConstructView(CEnvCell) (pc:433750-433792): master_timestamp++, InitCell(root, 0xffff), InsCellTodoList(root, 0f), then a worklist loop popping the NEAREST cell (cell_todo_list is sorted by InitCell's max/min portal-vertex distance), appending it to cell_draw_list, and running ClipPortals + AddViewToPortals on it (pc:433786-433787).
PView::InitCell (Ghidra 0x005a4b70, pc:432896): per portal of the cell, classify the EYE against the portal polygon's plane with F_EPSILON = 0.0002 (acclient.h-adjacent const at 0x7e32f8): dist > +eps → side 0, dist < -eps → side 1; if side != portal_side the portal faces AWAY (inflag=1, not traversable); if side == portal_side OR |dist| <= eps (the knife-edge in-plane case) → inflag=0, candidate. The portal you ENTERED through (index == arg3) is force-marked inflag=1/seen=1 so the flood never walks back. PView::ClipPortals (Ghidra/pc:433572): for each portal with seen && !inflag, resolve the neighbour (CEnvCell::GetVisible), then FOR EACH accumulated view i of this cell (Render::set_view(&view, i) then PView::GetClip(portal_side, portal_poly, &clip_view, &n, 1) pc:433651): project the portal polygon to homogeneous screen space and software-clip it against the INSTALLED view. If the portal leads outside (other_cell_id == 0xffffffff) and cliplandscape (default 1, 0x00820f4c) → Render::copy_view(&pview->outside_view, clip, n) appends the clipped aperture to outside_view (pc:433668-433676); else after PView::OtherPortalClip (pc:433524, the neighbour-side reciprocal re-clip through the matching back-portal indexed DIRECTLY by other_portal_id, 0x005a54b2/0x005a54f6) → Render::copy_view(neighbour.top_view, clip, n) APPENDS the polygon to the NEIGHBOUR's view list (pc:433674, target resolved via num_view/portal_view at 0x134/0x138).
MULTI-PORTAL ACCUMULATION: a cell visible through multiple portals accumulates MULTIPLE view polygons — copy_view (Ghidra 0x0054dfc0) perspective-divides the clipped verts, merges vertices closer than ~1 PIXEL (|dx|<=1 && |dy|<=1 screen units), appends a new view_poly + grows view_count. It is a UNION-AS-LIST; polygons are never merged geometrically. PView::AddViewToPortals (pc:433446, 0x005a52d0): for each portal whose clip produced something, if the neighbour was never seen this timestamp → InitCell + InsCellTodoList (enqueue ONCE); if already seen and its view GREW (0x44 watermark != 0x38 view_count) → AddToCell IN PLACE and, if the cell was already drawn-listed, FixCellList = AdjustCellPlace (re-sorts cell_draw_list so the grown cell draws in dependency order, pc:433247) + AdjustCellView (re-clips ONLY the new views: ClipPortals(cell, update_count), pc:433741-433745). There is NO re-enqueue and no iteration cap — growth propagates recursively in place, and the 1-px vertex dedup gives the fixpoint a hard floor.
WHAT set_view INSTALLS: Render::set_view(view_type*, n) (pc:343750, 0x0054d0e0) sets Render::portal_view/portal_view_num, portal_npnts = poly.vertex_count, portal_inmask = (1<<(npnts+1))-1, portal_vertex = &vertex.data[poly.vertex_index] (the screen points + eye-edge planes), and the 2D xmin/xmax/ymin/ymax bbox. This is GLOBAL clipper state consumed by polyClipFinish and viewconeCheck.
THE SOFTWARE CLIP: ACRender::polyClipFinish (Ghidra 0x006b6d00, pc:702749) is a Sutherland-Hodgman clipper in HOMOGENEOUS screen coordinates (Vec2Dscreen = xw,yw,zw,w): stage 1 clips against w = cdstW (the near/eye plane, interpolating all four homogeneous components — no divide, so eye-grazing portals never blow up); stage 2 clips against each of the installed view's portal_npnts edges using the perspective-correct 2D test (xw - x_edge*w)dy - (yw - y_edgew)*dx, gated per-edge by the planeMask shifted by (0x1e - npnts) — a SET bit means SKIP that edge. <3 surviving verts → output count 0. It is pixel-exact because (a) it clips polygon-vs-polygon with no plane-count budget (views are DArrays, blocksize 0x80; the loop runs all npnts edges), and (b) the homogeneous interpolation is exactly the rasterizer's math.
THE DRAW — THE LOAD-BEARING SURPRISE: retail NEVER clips cell geometry. RenderDeviceD3D::DrawEnvCell (0x0059f170, pc:427880-427930): production path is the prebuilt D3D mesh — D3DPolyRender::DrawMesh(num_surfaces, surfaces, constructed_mesh, 1) (pc:427905) built once at CEnvCell::UnPack (Ghidra 0x0052d470, ConstructMesh of ALL structure->polygons, 3.0 detail, use_built_mesh=1). The legacy poly-list path submits every polygon with planeMask=0xffffffff (pc:427922) — and 0xffffffff after the (0x1e-npnts) shift has the sign bit set for every edge iteration, i.e. SKIP ALL VIEW EDGES; moreover D3DPolyRender::polyListFinishInternal (Ghidra 0x0059dba0) just calls DrawPolyInternal per poly, and DrawPolyInternal (Ghidra 0x0059d7c0) does NO view clipping whatsoever — it builds a triangle fan from the 3D verts and calls DrawPrimitiveUP, gated only by (surface->type & 6) != 0 (BASE1_IMAGE|BASE1_CLIPMAP, acclient.h:5820-5824). The accumulated views gate ADMISSION, OBJECT CULLING and PUNCHES — never geometry pixels.
PIXEL-EXACTNESS = DEPTH PUNCH + ORDER + Z-BUFFER. PView::DrawCells (Ghidra 0x005a4840): pass 1 (only when outside_view.view_count != 0): PortalList=&outside_view, LScape::draw FIRST (landscape through the accumulated outside views), FlushAlphaList, m_nFrameStamp++, then for every cell far→near, FOR EACH VIEW (CEnvCell::setup_view(cell, i) = set_view of view i, Ghidra 0x0052c430), every portal with other_cell_id == -1 (to landscape) gets D3DPolyRender::DrawPortalPolyInternal(poly, false). That function (Ghidra 0x0059bc90, pc:424490) projects the aperture polygon, SOFTWARE-CLIPS it against the installed view via polyClipFinish(mask=0 → clip ALL edges), and draws the clipped polygon as an INVISIBLE DEPTH-ONLY quad: DEPTHTEST_ALWAYS, z-write on, z = the portal's true projected depth zw/w when maxZ2=6 (bit0 clear; 0x00820e14) or z = 0.99999988 (far plane) when maxZ1=7 (bit0 set; 0x00820e18); alpha byte 0 so no color. Writing the DOOR-PLANE depth into the aperture after the landscape protects the landscape pixels: any interior geometry FARTHER than the door fails the z-test inside the aperture; nearer geometry draws normally. Pass 2 draws cells far→near, per view, via vtbl+0x5c = DrawEnvCell (vtable at pc:1037045, 0x7e555c) — the GetDrawnThisFrame guard (Ghidra 0x0052c0c0, == m_nFrameStamp) makes the per-view repeats no-ops, so cell GEOMETRY draws ONCE, unclipped; the per-view loop only matters for punches and object culling. Epilogue: per cell, PortalList = the cell's view, vtbl+0x64 = DrawObjCell — objects are tested per view by Render::viewconeCheck (Ghidra 0x0054c250): bounding sphere vs the camera plane AND each view_vertex.plane of the installed view (stride 6 floats), OUTSIDE → skipped; objects are CULLED, never clipped (RenderDeviceD3D::DrawMesh loops PortalList views with building_view filtering, pc:427940-428060 / 0x005a0860).
OUTSIDE-LOOKING-IN: RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:427938): set outdoor_pview->outdoor_portal_list = building->portals, then CPhysicsPart::Draw(part, 1) → DrawMeshInternal arg3=1 → TWO drawing-BSP walks BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (.., 2) (pc:427993-427994, 0x0059f3cc/0x0059f3d9); each BSPPORTAL node fires RenderDevice::DrawPortal(portalPoly, 1, mode) (pc:326947-326992, 0x0053d870) → PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895) → ConstructView(CBldPortal) (Ghidra 0x005a59a0, pc:433827). Mode 1 = eye-side gate (F_EPSILON, IN_PLANE → return 0 — building portals reject the knife-edge OUTRIGHT), GetClip vs the current view, copy_view into the interior cell, then PUNCH the door aperture to FAR-Z (param_4==1 → maxZ1) and stop. Mode 2 = no punch, RECURSE ConstructView(cell, other_portal_id) building the interior view graph, then DrawCells(this, 1) draws the interior cells into the punched aperture. THEN CPhysicsPart::Draw(part, 0) draws the building SHELL mesh last; interior pixels survive only inside the punched aperture (everywhere else the shell's nearer depth rejects them). Pixel-exact, again with zero geometry clipping.
KNIFE-EDGE (Q3): two distinct behaviors. Building portals (ConstructView(CBldPortal), Ghidra 0x005a59a0): |eye·N + d| <= 0.0002 → Sidedness IN_PLANE → return 0; the portal contributes nothing that frame; no degenerate view is ever built. Cell-to-cell portals (InitCell, Ghidra 0x005a4b70): the in-plane case leaves inflag=0 (candidate) and the degenerate projection dies naturally downstream — polyClipFinish's homogeneous near-W clip plus copy_view's 1-pixel vertex dedup collapse a sub-pixel sliver to <3 verts → no view appended → no propagation. Physically correct: an edge-on aperture subtends zero pixels.
8 PLANES (Q4): confirmed unbounded. Views are DArray-backed (grow on demand, blocksize 0x80); polyClipFinish iterates all portal_npnts edges; the only fixed-width artifact is the 32-bit planeMask (shift by 0x1e-npnts) which the D3D path ignores entirely. There is no plane budget and no fallback tier.
CELL PORTAL POLYS (Q5): they live in the drawn polygon array (UnPack, 0x00533d00) and ARE emitted into the built mesh — D3DPolyRender::ConstructMesh (Ghidra 0x0059dfa0) has NO stippling or portal gate in either the counting or emission loop. Suppression is DATA + SURFACE-GATE: in the Holtburg cellar dat (a8-current-room-cellar-audit.txt / corner-cells-audit.txt, EnvCell 0xA9B40175), portal polys carry stippling NoPos and pos_surface → 0x080000DF (an untextured surface), and the draw gate skips untextured batches: DrawPolyInternal requires (type & 6) != 0 (0x0059d7c0), and DrawMesh (Ghidra 0x0059d4a0, pc:426064) skips a batch when skipNoTexture (default 1, 0x00820e30) && !(type & 6), with the bypass branch only for non-building/non-cell meshes. So cell apertures are never VISIBLY drawn; the only per-frame conditional draw of a cell portal poly is the invisible depth punch (DrawCells pass 1 for landscape portals; ConstructView/DrawPortal for building doors). Cell-to-cell apertures inside a dungeon get NO punch at all — plain z-buffer + far→near order suffices there.
ACDREAM
FRAME ENTRY. GameWindow decides per frame on clipRoot (the VIEWER cell or the synthetic outdoor node): indoor/outdoor-node frames run ONLY RetailPViewRenderer.DrawInside (GameWindow.cs:7590-7604); the global terrain/sky block runs only when clipRoot is null (GameWindow.cs:7546-7589). The outside→in look (retail DrawPortal) exists as RetailPViewRenderer.DrawPortal driven from GameWindow.cs:7750-7780 via BuildFromExterior seeding.
VIEW CONSTRUCTION. PortalVisibilityBuilder.Build (PortalVisibilityBuilder.cs:63-381) is the ConstructView port: root view = full-screen NDC quad (PortalVisibilityBuilder.cs:77, 557-558), distance-priority CellTodoList (PortalVisibilityBuilder.cs:96-97), per-portal side test CameraOnInteriorSide with PortalSideEpsilon = 0.01 (PortalVisibilityBuilder.cs:38, 734-741), portal projection ProjectToClip = homogeneous transform + eye-plane-only clip at w >= 1e-4 (PortalProjection.cs:81-97), then ClipToRegion = homogeneous Sutherland-Hodgman against each active view polygon with w-multiplied edge tests (PortalProjection.cs:105-134) — a faithful polyClipFinish equivalent. Clipped regions append to the neighbour's CellView (union-as-list, AddRegion with dedup) and exit portals append to OutsideView (PortalVisibilityBuilder.cs:269-281, 334-336). Reciprocal OtherPortalClip by direct OtherPortalId index is ported (PortalVisibilityBuilder.cs:305-332, 755-764). Late view growth RE-ENQUEUES the cell, capped at MaxReprocessPerCell = 16 because ProjectToClip numerical drift otherwise never settles (PortalVisibilityBuilder.cs:40-51, 348-354); OrderedVisibleCells appends once on first pop and is never re-sorted (PortalVisibilityBuilder.cs:168-172). A clip-empty portal whose opening the eye stands inside (within 1.75 m) is RESCUED by substituting the whole current view (PortalVisibilityBuilder.cs:258-267). Per-building outdoor floods: ConstructViewBuilding == BuildFromExterior (PortalVisibilityBuilder.cs:548-554), merged by MergeBuildingFrame FIRST-WINS — a cell already present in the frame keeps its existing views and the building flood's views are dropped (RetailPViewRenderer.cs:151-160).
CLIP ASSEMBLY. ClipFrameAssembler.Assemble (ClipFrameAssembler.cs:78-196) packs ONE GPU clip slot per view polygon: ClipPlaneSet.From converts a single CCW NDC polygon of 3..8 edges (after ~0.5° collinear merge) into <=8 clip-space half-planes (nx,ny,0,d) (ClipPlaneSet.cs:135-149); >8 edges → scissor AABB fallback (ClipPlaneSet.cs:130-133) which the assembler maps to SLOT 0 = PASS-ALL with the renderer-side scissor documented as unimplemented (ClipFrameAssembler.cs:13-15, 114-119); degenerate/area<1e-7 → IsNothingVisible → slice omitted (ClipPlaneSet.cs:66-68, 241-242 + ClipFrameAssembler.cs:102-103). CellIdToSlot keeps only slices[0] for single-slot consumers (ClipFrameAssembler.cs:130).
DRAW. RetailPViewRenderer.DrawInside (RetailPViewRenderer.cs:44-109): build frame → (outdoor root) merge per-building floods → assemble clip slots → PrepareRenderBatches for all OrderedVisibleCells → DrawLandscapeThroughOutsideView: per outside slice, terrain UBO planes set + landscape drawn CLIPPED to the slice planes via gl_ClipDistance in terrain/sky shaders (RetailPViewRenderer.cs:214-238; sky.vert:153, terrain_modern.vert:47), then ClearDepthSlice per slice = scissored AABB depth CLEAR to far (indoor roots only; null for outdoor roots) (GameWindow.cs:7644-7652) → DrawExitPortalMasks — UNWIRED, GameWindow never sets the callback so it no-ops (RetailPViewRenderer.cs:331-332; absent from the ctx at GameWindow.cs:7604-7670) → DrawEnvCellShells far→near per IndoorDrawPlan.ShellPass (IndoorDrawPlan.cs:18-29), per view slice, with UseShellClipRouting routing the cell to its slice slot and the shell vertex shader writing gl_ClipDistance[i] = dot(planes[i], gl_Position) (mesh_modern.vert:120; EnvCellRenderer.cs:262, 1195-1230) — but GL clip distances are ENABLED only when clipShells == ctx.RootCell.IsOutdoorNode (9ce335e #114 scoping; RetailPViewRenderer.cs:96-105, 378-398): indoor roots draw shells UNCLIPPED → DrawCellObjectLists: entities drawn with membership cull only (UseIndoorMembershipOnlyRouting clears clip routing, RetailPViewRenderer.cs:439-450; the comment cites retail viewconeCheck but no per-view sphere-vs-plane test exists), and particles drawn per cell SCISSORED to the slice NDC AABB with clip distances disabled (GameWindow.cs:9553-9580). Cell-side portal polys are suppressed at mesh-extraction time by StipplingType.NoPos/NoNeg (ObjectMeshManager.cs:1385-1402 in PrepareCellStructMeshData, reached from the EnvCell path at ObjectMeshManager.cs:1343-1350) — a different criterion from retail's untextured-surface batch skip, agreeing on the audited cellar data.
DIVERGENCES
[CRITICAL] shell-chop-vs-depth-discipline (UNVERIFIED (verifier hit token limit)) — acdream clips cell-shell GEOMETRY to the view region; retail clips NOTHING — pixel exactness comes from aperture depth-punch + far→near order + z-buffer
- blastRadius: #114 in full (chopped interior stairs, vanished candle-holder area, neighbour-room barrel visible through a chopped wall — all are under-inclusive regions amputating real geometry), the e46d3d9→124c6cb door-regression cycle, and the structural reason
9ce335ehad to scope the clip back out of indoor roots (leaving indoors with NO draw-side discipline at all). This is the one-drawing-discipline invariant breaker. - retailEvidence: DrawEnvCell submits polys with planeMask=0xffffffff (pc:427922) which the shift in polyClipFinish (Ghidra 0x006b6d00: mask << (0x1e - npnts), set bit = SKIP edge) turns into skip-all; the production path is the prebuilt mesh DrawMesh(…, constructed_mesh, 1) (pc:427905) and DrawPolyInternal (Ghidra 0x0059d7c0) performs zero view clipping — a raw triangle fan to D3D. The accumulated view is consumed ONLY by: admission (ClipPortals/copy_view), object culling (viewconeCheck Ghidra 0x0054c250), and the depth punch (DrawPortalPolyInternal Ghidra 0x0059bc90: polyClipFinish-clipped aperture drawn DEPTHTEST_ALWAYS, z-write, alpha 0, z = portal depth (maxZ2=6 @0x00820e14) or far-z (maxZ1=7 @0x00820e18)). Cell geometry is always drawn whole; cells draw once (GetDrawnThisFrame guard Ghidra 0x0052c0c0) far→near (DrawCells Ghidra 0x005a4840).
- acdreamEvidence: DrawEnvCellShells enables GL_CLIP_DISTANCE0..7 and hard-clips shell vertices to the per-slice region planes (RetailPViewRenderer.cs:378-398; mesh_modern.vert:120; EnvCellRenderer.cs:1195-1230) — for outdoor roots only after
9ce335e(RetailPViewRenderer.cs:96-105); indoor roots draw shells unclipped with no compensating depth discipline. The region polygons themselves drift per frame (PortalVisibilityBuilder.cs:40-51 cap rationale), so any chop is also unstable. - portShape: Remove the shell gl_ClipDistance chop as the enforcement mechanism (keep regions for admission). Port the retail discipline: draw every admitted cell's shell WHOLE, far→near per OrderedVisibleCells (already the order, IndoorDrawPlan.cs:21), and enforce aperture exactness with depth-only punches of the software-clipped portal polygons (see next divergence). gl_ClipDistance may survive only as the LANDSCAPE gate (terrain has no z-protection of its own until the punch exists).
[CRITICAL] missing-aperture-depth-punch (UNVERIFIED (verifier hit token limit)) — The retail depth punch (DrawPortalPolyInternal) has no acdream equivalent — DrawExitPortalMasks is an unwired no-op and ClearDepthSlice clears an AABB to far instead of writing portal-plane depth on the exact clipped polygon
- blastRadius: Landscape-vs-interior compositing at every aperture: far interior cells can overpaint the terrain seen through a door (no door-plane z floor), the outdoor root has NO depth discipline at building doors at all (ClearDepthSlice=null), and the AABB-shaped clear over-includes around door frames — a direct candidate mechanism for #108 (grass sweeping at the aperture edge) and a contributor to the #114 see-through-to-neighbour-rooms class.
- retailEvidence: DrawCells pass 1 (Ghidra 0x005a4840): after LScape::draw, per cell per view, every other_cell_id==-1 portal is punched at its TRUE projected depth (maxZ2=6, bit0 clear → z=zw/w; Ghidra 0x0059bc90 tail) so geometry behind the door plane z-fails inside the aperture while the landscape keeps its pixels. Outside→in: ConstructView(CBldPortal) mode-1 walk punches the door to FAR-z (maxZ1=7) before the mode-2 walk draws interior cells into it, and the shell mesh draws LAST (DrawBuilding Ghidra 0x0059f2a0: Draw(part,1) then Draw(part,0); walks at pc:427993-427994). The punched polygon is the polyClipFinish-clipped aperture — pixel-exact.
- acdreamEvidence: RetailPViewRenderer.DrawExitPortalMasks exists but GameWindow never supplies the callback (RetailPViewRenderer.cs:331-332; ctx construction GameWindow.cs:7604-7670 sets only DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles/EmitDiagnostics). The only depth management is ClearDepthSlice: glScissor on the slice NDC AABB + Clear(DepthBufferBit) — wrong shape (AABB ⊇ polygon), wrong value (far clear ≈ retail's maxZ1 special case, never the protective portal-depth maxZ2 write), and disabled outright for outdoor roots (GameWindow.cs:7644-7652).
- portShape: Wire DrawExitPortalMasks as a depth-only polygon draw: project + software-clip the aperture polygon against the slice view (ClipToRegion already does this math, PortalProjection.cs:105-134), rasterize it depth-always/z-write/color-masked at the portal's interpolated depth (indoor→out, retail maxZ2) or far-z (outside→in before interior cells, retail maxZ1), replacing ClearDepthSlice. ~80 lines of GL + the existing clipper.
[HIGH] multiview-loss-first-wins (UNVERIFIED (verifier hit token limit)) — Multi-portal view accumulation is lossy: MergeBuildingFrame drops a building flood's views when the cell is already in the frame, and CellIdToSlot keeps only slices[0]
- blastRadius: A cell visible through TWO apertures (two doors, door+window) renders/punches/scissors with only the first view — missing second-door visibility, and per-frame winner flips drive oscillation: a named suspect for #109 (far-door oscillation) and the multi-aperture cases in #114 (meeting hall).
- retailEvidence: Render::copy_view APPENDS every clipped portal polygon as a new view_poly (Ghidra 0x0054dfc0: poly DArray grow + view_count++; called per portal per view from ClipPortals pc:433674 and ConstructView(CBldPortal) Ghidra 0x005a59a0). DrawCells iterates ALL views per cell for punches and object culling (Ghidra 0x005a4840 per-view loops; DrawMesh per-view viewconeCheck pc:427940-428060). AddViewToPortals propagates late growth in place via AddToCell/FixCellList/AdjustCellView (pc:433446, 433741-433745).
- acdreamEvidence: MergeBuildingFrame:
if (target.CellViews.ContainsKey(cellId)) continue;— first-wins, src views discarded (RetailPViewRenderer.cs:151-160). ClipFrameAssembler.CellIdToSlot = sliceArray[0].Slot (ClipFrameAssembler.cs:130) feeds the single-slot consumers (entity routing RetailPViewRenderer.cs:227). The per-slice shell loop itself does handle multiple slices (RetailPViewRenderer.cs:388-393), so the loss is at merge/routing, not the draw loop. - portShape: Merge = UNION the view lists (append src polygons through the existing AddRegion dedup) instead of skipping; once the shell chop is gone (divergence 1) multi-slice only matters for punches/scissors/object culling, where iterating all slices is already the code shape.
[HIGH] eight-plane-budget-passall (UNVERIFIED (verifier hit token limit)) — The 8-plane GPU budget with an unimplemented scissor fallback (slot-0 = PASS-ALL) has no retail counterpart — retail's clip is polygon-vs-polygon software with no plane cap
- blastRadius: Any view polygon with >8 edges after collinear-merge silently degrades to pass-all: terrain slices flood the whole screen through a small aperture (grey/grass artifacts at complex doorways, #108 class), and under the current shell-chop model a per-frame flip between exact-planes and pass-all is a visible strobe. Issue113MeetingHallFloodTests pins 0 such slices at the hall, but the fallback is load-bearing wherever clipped polygons accrete vertices (every reciprocal clip adds edges).
- retailEvidence: polyClipFinish loops all portal_npnts view edges with DArray-backed vertex storage (Ghidra 0x006b6d00; view DArrays blocksize 0x80, acclient.h:32408-32445); the only 32-bit-mask artifact is ignored by the D3D draw path (DrawPolyInternal Ghidra 0x0059d7c0 takes no mask). copy_view's 1-pixel vertex dedup (Ghidra 0x0054dfc0) bounds vertex growth physically, not by a budget.
- acdreamEvidence: ClipPlaneSet: >8 edges → Scissor AABB (ClipPlaneSet.cs:130-133); assembler maps scissor fallbacks to slot 0 with 'the renderer uses scissor for passes that need that fallback' documented but no glScissor implemented in the slice consumers (ClipFrameAssembler.cs:13-15, 114-119; no scissor in RetailPViewRenderer draw paths — only particles use BeginDoorwayScissor, GameWindow.cs:9569).
- portShape: Once enforcement moves to the depth punch (CPU-rasterized clipped polygons), the 8-plane budget stops being load-bearing for shells; the landscape gate either keeps planes for the common ≤8 case with a real scissor (or stencil) fallback, or the punch protects terrain too and the budget disappears entirely. Do not extend gl_ClipDistance count.
[HIGH] knife-edge-epsilon-and-rescue (UNVERIFIED (verifier hit token limit)) — Knife-edge handling diverges: retail uses ±0.0002 side classification (building portals reject IN_PLANE outright; cell portals degenerate naturally via the homogeneous near-W clip + 1-px dedup); acdream uses ±0.01 plus a non-retail 1.75 m eye-in-opening rescue that substitutes the ENTIRE current view
- blastRadius: #114's edge-on doorway grey (degenerate slice omitted → cell admitted by rescue but landscape/region slice missing → clear color through the aperture) and admission over-inclusion when grazing a doorway; the 50x-wider epsilon plus the rescue create a band where acdream and retail disagree about which portals contribute, feeding flap-class instability at thresholds.
- retailEvidence: ConstructView(CBldPortal) Ghidra 0x005a59a0: |dot| <= F_EPSILON (0.000199999995, const at 0x7e32f8) → IN_PLANE → return 0, no view, no punch. InitCell Ghidra 0x005a4b70: cell-portal in-plane falls through to inflag=0 (candidate) and the sliver dies in polyClipFinish stage-1 (w < cdstW homogeneous clip) or copy_view's |dx|<=1px && |dy|<=1px vertex merge → <3 verts → nothing appended. No rescue path exists anywhere.
- acdreamEvidence: PortalSideEpsilon = 0.01 (PortalVisibilityBuilder.cs:38, used at :734-741); clip-empty + EyeInsidePortalOpening(≤1.75 m) → clippedRegion := copy of the WHOLE current view (PortalVisibilityBuilder.cs:258-267, same in BuildFromExterior :501-507 and the reciprocal-empty rescue :324-332); ClipPlaneSet's MinPolygonArea=1e-7 gate turns surviving slivers into omitted slices (ClipPlaneSet.cs:66-68, 241-242) while the cell stays admitted.
- portShape: Adopt retail's constants and asymmetry: 0.0002 epsilon; building/exterior seeds reject in-plane; cell portals keep the candidate-then-degenerate path with a ~1-px screen-space vertex dedup in ClipToRegion output (the missing stabilizer that retail has and our drift cap compensates for). The rescue should shrink to what retail's geometry already implies — a portal the eye is truly inside projects near-full-screen through the homogeneous clip and needs no substitution.
[MEDIUM] growth-requeue-vs-in-place (UNVERIFIED (verifier hit token limit)) — Late view growth: retail propagates in place (AddToCell + FixCellList/AdjustCellPlace re-sorts the draw list, AdjustCellView re-clips only new views via the update_count watermark); acdream re-enqueues with a drift cap and never re-sorts OrderedVisibleCells
- blastRadius: Draw-order staleness for late-grown cells (mostly masked by z-buffer) and the MaxReprocessPerCell=16 cap silently truncating legitimate propagation in portal-dense interiors — a churn/oscillation contributor (#109) and the source of the 2026-06-07 indoor-hang workaround complex.
- retailEvidence: AddViewToPortals pc:433446/0x005a52d0: first discovery → InitCell+InsCellTodoList; growth → AddToCell, and if cell_view_done, FixCellList = AdjustCellPlace (re-sort cell_draw_list, pc:433247) + AdjustCellView (ClipPortals(cell, update_count) → only NEW views re-clipped, pc:433741-433745; watermark fields 0x38/0x44 read at 0x005a5357-0x005a53b8). Termination comes from the 1-px dedup floor, not a cap.
- acdreamEvidence: Re-enqueue on grew with popCounts cap (PortalVisibilityBuilder.cs:348-354) and the cap's own comment attributing it to ProjectToClip drift (PortalVisibilityBuilder.cs:40-51); processedViewCounts is a faithful update_count port (PortalVisibilityBuilder.cs:174-184); OrderedVisibleCells append-once, never adjusted (PortalVisibilityBuilder.cs:168-172).
- portShape: With the dedup stabilizer (divergence 5) the cap becomes removable; the faithful shape is in-place propagation: on growth of an already-popped cell, immediately re-clip only the new view slice through its portals (recursive, like AdjustCellView) and re-position the cell in the ordered list (AdjustCellPlace) — or document why append-order + z-buffer makes re-positioning unnecessary in our GL pipeline.
[MEDIUM] object-particle-gating (UNVERIFIED (verifier hit token limit)) — Objects/particles: retail culls per view by sphere-vs-view-edge-planes (viewconeCheck) and lets depth do the pixels; acdream culls by cell membership only and scissors particles to the slice NDC AABB
- blastRadius: particles-through-walls (AABB ⊇ aperture polygon → emitters visible outside the true opening), neighbour-room objects drawn whenever their cell is admitted even when their sphere is outside every view (the barrel half of the #114 barrel-through-wall once the wall chop is fixed, the object side remains over-inclusive), minor overdraw cost.
- retailEvidence: viewconeCheck Ghidra 0x0054c250: sphere vs viewer plane + each installed view_vertex.plane (stride 6 floats = Vec2D pt + Plane, acclient.h:32483-32487), OUTSIDE → skip; driven per view from RenderDeviceD3D::DrawMesh when PortalList is set (pc:427940-428060, building_view filter) and per cell from the DrawCells epilogue (PortalList = cell's view before vtbl+0x64 DrawObjCell, Ghidra 0x005a4840). No scissor, no geometry clip — depth + culling only.
- acdreamEvidence: UseIndoorMembershipOnlyRouting clears all clip routing for entities and the comment explicitly defers the viewconeCheck equivalent (RetailPViewRenderer.cs:439-450); entity draw passes visibleCellIds membership only (RetailPViewRenderer.cs:460-477); particles: DisableClipDistances + BeginDoorwayScissor(slice.NdcAabb) (GameWindow.cs:9553-9580).
- portShape: Port viewconeCheck: each ClipViewSlice already has the NDC edge set — lift the per-edge eye-planes (view_vertex.plane analog: the plane through the eye and the NDC edge, recoverable from the inverse view-projection) and test entity/emitter bounding spheres per slice before draw; drop the particle scissor once the punch protects apertures.
[MEDIUM] portal-poly-suppression-criterion (UNVERIFIED (verifier hit token limit)) — Cell-side portal-poly suppression keys on the wrong property: acdream gates on Stippling NoPos/NoNeg at mesh build; retail includes them in the mesh and skips UNTEXTURED surface batches at draw (skipNoTexture + type&6)
- blastRadius: Anywhere stippling and surface-texturedness disagree the two renderers diverge: a TEXTURED portal poly without NoPos (window-filling between cell and outdoors, closed-door fillings) draws in retail but our criterion may pass or drop it for the wrong reason; this is the same mechanism family as the #113 phantom staircase / door-vanish on the GfxObj side (
e223325) viewed from the cell side. On the audited cellar cells the criteria agree (both suppress), so blast radius today is latent, not pinned to an open issue. - retailEvidence: ConstructMesh (Ghidra 0x0059dfa0) emits ALL polygons — no stippling/portal gate in counting or emission loops; CCellStruct::UnPack (Ghidra 0x00533d00) places portal polys inside the polygons array; the draw skip is per-surface: DrawPolyInternal requires (surface->type & 6) != 0 (Ghidra 0x0059d7c0; BASE1_IMAGE|BASE1_CLIPMAP acclient.h:5820-5824) and DrawMesh skips untextured batches when skipNoTexture (default 1 @0x00820e30) except for plain objects (Ghidra 0x0059d4a0 / pc:426064-426074). Dat evidence: cellar portal polys have stip=NoPos AND pos_surface→0x080000DF (untextured) — corner-cells-audit.txt EnvCell 0xA9B40175 polys 0x0004/0x0005.
- acdreamEvidence: PrepareCellStructMeshData: hasPos/hasNeg from StipplingType.NoPos/NoNeg decide emission entirely (ObjectMeshManager.cs:1394-1402); a NoPos poly is dropped from the mesh before any surface consideration; conversely a portal poly WITHOUT NoPos would be emitted and drawn textured with no draw-time portal awareness (ObjectMeshManager.cs:1343-1350 entry).
- portShape: Align the criterion with retail: emit all polys, classify batches by surface texturedness (we already read Surface.Type at ObjectMeshManager.cs:1430+), and skip untextured batches for cell/building meshes at draw — or prove from a dat sweep that NoPos ⇔ untextured-surface for all CellStructs (extend the
e223325conformance sweep to environments) and keep the cheaper build-time gate with the proof pinned.
OPEN QUESTIONS
- LScape::draw internals: whether retail clips terrain POLYGONS against outside_view in software or only culls land blocks/cells per view (PortalList is installed before LScape::draw in DrawCells pass 1, Ghidra 0x005a4840, but I did not decompile LScape::draw). The punch+order discipline works either way, but a faithful landscape pass should know which; acdream currently plane-CLIPS terrain per slice, which retail may not do at all.
- PView::InitCell's second loop (Ghidra 0x005a4b70 tail): the decompile shows seen=1 set for every inflag==0 portal per view with no visible screen-bbox test — either a test was optimized out of the decompile or 'seen' is simply 'candidate'. The exact seen gate (and whether it uses the view xmin/xmax bbox installed by set_view) is unconfirmed.
- Who calls PView::DrawPortal with mode 3 (punch-on-ConstructView-failure, Ghidra 0x005a5ab0)? The BSP walks pass modes 1 and 2 (pc:427993-427994); mode 3 implies a 'seal this aperture in depth even though nothing is visible through it' caller I did not locate — possibly the unloaded-interior or option-disabled path. Matters for the port's behavior when an interior cell is not streamed in.
- GfxObj-side (building shell) portal-poly surfaces: the cell-side data proves the untextured-surface skip for the cellar; whether the Holtburg building models' door/window-filling polys split into textured (visible filling) vs untextured (open aperture) the way the skipNoTexture gate requires is AREA 1's question — the
e223325test dump should be extended to record each portal poly's pos_surface type bits before the holistic port relies on this gate. - Retail DrawMesh's garbled else-branch (
skipNoTexture = 1when ObjBuildingOrBuildingPart==0 && param_4==0, both BN pc:426074 and Ghidra agree on the assignment) — semantically it looks like a latch that should be an assignment to 0 or a one-shot draw-anyway; since skipNoTexture init is already 1 (0x00820e30) the net effect (skip untextured for buildings/cells, draw for plain objects) holds, but the branch's exact intent deserves a disassembly-level check before porting it verbatim. - Spatially overlapping cells within one dungeon: retail punches ONLY landscape portals (other_cell_id==-1) and building doors — cell-to-cell apertures get no punch, so retail relies on z-buffer + non-overlapping cell volumes. Whether any shipped dungeon has same-dungeon overlapping cell volumes (which would bleed under the unclipped discipline and therefore under a faithful port too) is a dat question worth a one-off sweep before declaring the port's z-only indoor compositing sufficient.
- cdstW's exact value (the homogeneous near-W threshold in polyClipFinish, Ghidra 0x006b6d00): not extracted; acdream's EyePlaneW=1e-4/MinW=0.05 (PortalProjection.cs:182-188) should be pinned to retail's constant during the port.