acdream/docs/research/2026-06-11-holistic-map/wf2-visibility-gates-audit.md
Erik 5e2f99d08e docs: Phase A comparison + Phase B port plan (holistic building-render investigation)
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>
2026-06-11 05:54:12 +02:00

170 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 2.5 — acdream-internal audit: every visibility/culling/clipping gate in today's frame; one-gate-rule violations and legacy remnants
## RETAIL
Retail has exactly ONE visibility product per frame and enforces it once for every geometry class. RenderNormalMode (0x453aa0) calls DrawInside(viewer_cell) unconditionally (pc:92675); PView::DrawInside (pc:433793) runs ConstructView (pc:433750) -> ClipPortals (pc:433572) -> AddViewToPortals (pc:433446), producing per-cell portal_view polygon lists + outside_view. PView::DrawCells (Ghidra 0x005a4840, decompiled this session) then enforces that single product in four stages: (0) if outside_view.view_count != 0, set Render::PortalList = &outside_view and call LScape::draw ONCE — the landscape is drawn one time under the whole outside_view region, followed by a z-buffer clear (vtbl+0x2c with flag 4, RGBAColor_Black, 1.0f) gated on portalsDrawnCount/forceClear; (1) reverse cell_draw_list, per view slice (CEnvCell::setup_view(cell, i)): DrawPortalPolyInternal for every portal with other_cell_id == -1 — the exit-portal mask/z pass; (2) reverse cell_draw_list, per setup_view slice: vtbl+0x5c (DrawEnvCell) — the shell pass, where every cell polygon is submitted with planeMask=0xffffffff (pc:427922) through the view planes Render::set_view installed (pc:343750, 0x0054d0e0); (3) reverse cell_draw_list: Render::PortalList = cell->portal_view.data[num_view-1], then vtbl+0x64 (DrawObjCell) — the object-list pass. Objects are NOT hard-clipped per slice; instead the mesh path gates each drawing sphere against the active viewcone: Render::viewconeCheck (Ghidra 0x0054c250) tests the sphere against the near plane (viewer_world_space.CY) plus portal_npnts view-edge planes, returning OUTSIDE / PARTIALLY_INSIDE / inside, and is called unconditionally from DrawMesh (xrefs 0x005a08e4, 0x005a09a4). So: one flood, one draw order, two enforcement mechanisms (hard poly clip for cell shells, viewcone sphere check for meshes) — but both read the SAME portal_view product. is_player_outside gates only sky/lighting, not the draw path.
## ACDREAM
FRAME ANATOMY (GameWindow.OnRender, src/AcDream.App/Rendering/GameWindow.cs:7124). Per frame: clear (DepthMask asserted true, :7155-7156), WbMeshAdapter.Tick (:7178), FrustumPlanes built from camera VP (:7221), lighting root = player CurrCell (:7291-7296), render root = RetailChaseCamera.ViewerCellId -> CellVisibility.TryGetCell (:7301-7312). THEN the dual-visibility surface: (A) _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos) at :7313 runs the full ACME-ported BFS (CellVisibility.cs:348-363 -> GetVisibleCellsFromRoot :455-515, eye-side portal test :493-506) every indoor frame, but its result is consumed ONLY as `bool cameraInsideCell = visibility?.CameraCell is not null` (:7314) — equivalent to `viewerRoot is not null`; VisibleCellIds and HasExitPortalVisible are produced and never read anywhere in src (grep: HasExitPortalVisible only at CellVisibility.cs:190/479). cameraInsideCell feeds only the debug-only UpdateSkyPes (:7343-7344, gated on EnableSkyPesDebug) and the [render-sig] probe (:7899). (B) The REAL gate: PortalVisibilityBuilder.Build inside RetailPViewRenderer.DrawInside (RetailPViewRenderer.cs:48-52) + per-building BuildFromExterior merges for the outdoor node (:60-61, 115-160). So yes — TWO live visibility computations run per indoor frame; only (B) gates pixels; they cannot disagree visibly today because (A)'s set is discarded, but (A) is live CPU + a drift trap (its doc says pass the player position, the call site passes the chase eye — CellVisibility.cs:343-347 vs GameWindow.cs:7313).
UNIFIED PATH (clipRoot != null = viewerRoot or OutdoorCellNode.Build, :7458-7482, :7497). Gates in order: [1] streaming window N1/N2 (what exists in _worldState.LandblockEntries + EnvCellRenderer registry); [2] PortalVisibilityBuilder flood (side test PortalVisibilityBuilder.cs:226-233, homogeneous portal clip via PortalProjection.ProjectToClip/ClipToRegion :81-134, reciprocal clip :779-823, eye-inside-opening rescues :258-267/:869 EyeStandingPerpDist=1.75m, MaxReprocessPerCell=16 cap :51/:348, CanonicalKey 1e-3 NDC snap-dedup PortalView.cs:115-164); [3] ClipFrameAssembler.Assemble — one slot (<=8 planes, ClipPlaneSet.cs:112-150) per view polygon; multi-polygon or >8 edges degrade to slot 0 + AABB scissor (ClipPlaneSet.cs:119-133, ClipFrameAssembler.cs:115-119); [4] drawableCells = ALL OrderedVisibleCells (RetailPViewRenderer.cs:71); [5] EnvCellRenderer.PrepareRenderBatches: cameraZ>4000 early-out (EnvCellRenderer.cs:555), camera-centred _nearRadius LB ring (:581-588), GpuReady (:590), WbFrustum.TestBox per LB (:612) + per-cell Intersects (:642), filter=drawableCells (:624/:630/:641); [6] landscape per OutsideView slice (RetailPViewRenderer.cs:223-232): scissor to slice NDC AABB (GameWindow.cs:9477, BeginDoorwayScissor :9707-9724), terrain UBO planes per slice (SetTerrainClip RetailPViewRenderer.cs:225, ClipFrame.cs:208-215), gl_ClipDistance enables (GameWindow.cs:9484/9689-9699), terrain per-slot FrustumCuller + neverCullLandblockId (TerrainModernRenderer.cs:206-218), outdoor entities clip-routed to the slice slot (SetClipRouting RetailPViewRenderer.cs:227 -> WbDrawDispatcher ResolveEntitySlot :425-455, SSBO binding=2/3 -> mesh_modern.vert:120), sky/weather clipped by the same terrain UBO (sky.vert:153); [7] depth-clear per slice scissored, indoor roots only — suppressed for the outdoor node (GameWindow.cs:7644-7652); [8] exit-portal masks: DORMANT — RetailPViewRenderer.DrawExitPortalMasks no-ops because neither production context sets the callback (RetailPViewRenderer.cs:331-332; the ctx initializers at GameWindow.cs:7604-7663 and :7780-7798 never assign DrawExitPortalMasks; no production StencilTest enable exists); [9] shells: IndoorDrawPlan.ShellPass (every flooded cell with non-empty view, far->near, IndoorDrawPlan.cs:18-29) x per slice, UseShellClipRouting (RetailPViewRenderer.cs:452-458), GL clip planes enabled ONLY when clipShells == RootCell.IsOutdoorNode (:104-105, :378-380) — indoor roots draw shells UNCLIPPED (#114 interim), cells without slices fall through to a full-screen NoClipSlice (:428-437); [10] object lists: per flooded cell far->near, InteriorEntityPartition.ByCell (InteriorEntityPartition.cs:22-79), UseIndoorMembershipOnlyRouting — clip routing explicitly cleared (:420, :439-450), so objects are gated ONLY by cell membership (WbDrawDispatcher.EntityPassesVisibleCellGate :1816-1835) + per-LB/per-entity FrustumCuller (:593-595, :662-666); [11] cell particles per cell per slice: clip distances OFF, scissor to slice AABB only, emitter filter AttachedObjectId != 0 && in-cell (GameWindow.cs:9553-9580); [12] LiveDynamic bucket (ServerGuid != 0, no ParentCellId) drawn unclipped+unfiltered ONLY for outdoor-node roots (:7716-7724); [13] global Scene-particle pass and post-scene weather run ONLY when clipRoot is null (:7846, :7874) — i.e. effectively never in normal play.
LEGACY PATH (clipRoot == null — pre-spawn / non-chase debug cameras, :7726-7831): sky+terrain ungated (no-clip ClipFrame, :7546-7587), global outdoor entity bucket via InteriorRenderer.DrawEntityBucket with an EMPTY visibleCells partition (:7732-7746 — all indoor-parented entities dropped), look-in via RetailPViewRenderer.DrawPortal over 1-ring candidate cells (:7748-7811) which uses a DIFFERENT membership rule (drawableCells = clipAssembly.CellIdToSlot.Keys, RetailPViewRenderer.cs:182 — the documented grey-walls under-include the DrawInside path fixed at :66-71), LiveDynamic fallback (:7813-7823), global scene particles INCLUDING AttachedObjectId==0 emitters (:7846-7868).
FILE CLASSIFICATION. CellVisibility.cs: PARTIALLY-LIVE — the cell REGISTRY half (AddCell/TryGetCell/GetCellsForLandblock/RemoveLandblock, :252-305) is the live backbone (root resolve GameWindow.cs:7294/7311, outdoor-node gather :7475, exterior candidates :7774, CellLookup :7613/:7786); the BFS half runs per frame (:7313) but its set is unconsumed; ComputeVisibility/GetVisibleCells test-only (:318-328 doc); IsInsideAnyCell dead in production (:414-419). InteriorRenderer.cs: PARTIALLY-LIVE — DrawInside (:63-101) has ZERO callers (dead since the RetailPViewRenderer cutover); DrawEntityBucket (:141-163) live at GameWindow.cs:7720/7739/7816. IndoorDrawPlan.cs: LIVE (RetailPViewRenderer.cs:382). PortalView.cs (ViewPolygon/CellView): LIVE (flood + assembler data model). PortalProjection.cs: PARTIALLY-LIVE — ProjectToClip/ClipToRegion live; ProjectToNdc (:32-71) has no production callers (tests only). OutdoorCellNode.cs: LIVE (GameWindow.cs:7478). FrustumCuller.cs (+FrustumPlanes): LIVE (terrain :216-218, dispatcher :593/:665, perf counter GameWindow.cs:8001). ScreenPolygonClip.cs: LEGACY-DEAD — referenced only in comments (PortalVisibilityBuilder.cs:801) and its own test file. Wb/EnvCellVisibilitySnapshot.cs: LIVE (EnvCellRenderer._activeSnapshot, EnvCellRenderer.cs:47/:718). Wb/WbFrustum.cs: LIVE (EnvCellRenderer ctor :188, updated GameWindow.cs:7396). ClipPlaneSet.cs: LIVE (ClipFrameAssembler.cs:101/:140). ClipFrame.cs/ClipFrameAssembler.cs: LIVE. RenderingDiagnostics.ShouldRenderIndoor: probe-only — playerIndoorGate no longer selects the path (GameWindow.cs:7485-7496).
NET: visibility is computed twice (one result discarded), and the ONE live flood product is enforced at FIVE different strengths — exact planes (outdoor-root shells), nothing (indoor-root shells), membership+frustum only (all object lists), planes+scissor (terrain/sky), scissor-rectangle only (particles) — where retail enforces one product through exactly two mechanisms (poly clip for shells, viewcone sphere check for meshes) that read the same view.
## DIVERGENCES
### [HIGH] object-lists-skip-portal-view-gate (confirmed) — Object lists are never gated by the portal view — no viewconeCheck equivalent exists
- correctedClaim: Confirmed as claimed, with two citation refinements: (1) DrawCells loop 3 calls vtbl+0x64 = RenderDeviceD3D::DrawObjCellForDummies (0x005a0760: UpdateObjCell + shadow-part insertion sort), which forwards to vtbl+0x60 DrawObjCell → DrawPartCell → CShadowPart::draw → CPhysicsPart::Draw → vtbl+0x70 DrawMesh; (2) viewconeCheck is called once per portal view inside DrawMesh's PortalList loop (gated by building_view == -1 || building_view == i), with set_view installing each view's edge planes first; the OUTSIDE-skip is absolute on the cell-object path because CShadowPart::draw passes force=0. Additionally, acdream's situation is worse than stated: the one geometric gate it does have (global frustum AABB, WbDrawDispatcher.cs:662-666) is bypassed for indoor buckets because DrawEntityBucket passes neverCullLandblockId equal to the bucket's LandblockId (RetailPViewRenderer.cs:465-474) — indoor cell objects are drawn with no geometric culling whatsoever.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. PView::DrawCells (Ghidra 0x005a4840): loop 3 (the object pass after the env-cell pass) sets `Render::PortalList = (cell->portal_view).data[cell->num_view - 1]` per cell, then calls render_device vtbl+0x64 with the cell. Verified verbatim in the decompile.
2. Vtable slot correction (minor): the RenderDeviceD3D vtable base is 0x007e5500 (pc:1037039-1037075 vtable dump), so vtbl+0x64 = 0x007e5564 = RenderDeviceD3D::DrawObjCellForDummies, NOT DrawObjCell (+0x60 = 0x007e5560). DrawObjCellForDummies (Ghidra 0x005a0760) does UpdateObjCell + CShadowPart::insertion_sort of the cell's shadow_part_list, then forwards to vtbl+0x60 = DrawObjCell (Ghidra 0x005a1a40). Substance unchanged. BN pc:432878 independently resolves the loop-3 call as DrawObjCellForDummies.
3. Full chain to the mesh gate, every link decompiled: DrawObjCell 0x005a1a40 → DrawPartCell 0x005a07a0 (iterates cell->shadow_part_list) → CShadowPart::draw 0x006b50d0 (calls CPhysicsPart::Draw(part, 0) — note force flag = 0) → CPhysicsPart::Draw 0x0050d7a0 → vtbl+0x70 = 0x007e5570 = RenderDeviceD3D::DrawMesh 0x005a0860 (vtable dump pc:1037075).
4. DrawMesh 0x005a0860: with Render::PortalList non-null (always true in the cell-object pass, set by loop 3), it loops over PortalList->view_count; for each view i (subject to `building_view == -1 || building_view == i`), calls Render::set_view(&PortalList->view, i) then Render::viewconeCheck(gfxobj->drawing_sphere). If OUTSIDE in every view and the force flag is false, returns OUTSIDE_VIEWCONE_ODS WITHOUT calling DrawMeshInternal — the mesh is skipped. The claimed xref addresses verify exactly: function_xrefs on viewconeCheck returns 0x005a08e4 (PortalList==null path) and 0x005a09a4 (per-view loop), both in DrawMesh. Wording correction (minor): "unconditionally" is loose — the call is per-portal-view inside a loop with the building_view filter, and an OUTSIDE result can still draw when the caller passes force=true; but the cell-object-list path passes force=false (CShadowPart::draw → Draw(part, 0)), so OUTSIDE ⇒ skip holds for exactly the statics/doors/NPCs path the claim is about.
5. Render::viewconeCheck (Ghidra 0x0054c250): transforms the drawing sphere's center to viewer space, tests signed distance against the viewer_world_space.CY forward plane, then against portal_npnts planes at portal_vertex; any distance < -radius OUTSIDE; else INSIDE/PARTIALLY_INSIDE. Render::set_view (Ghidra 0x0054d0e0, pc:343750) is what installs portal_npnts/portal_vertex/portal_inmask from view->poly.data[i] — confirming the planes tested ARE the per-cell accumulated portal-view edge planes, not just the global frustum.
ACDREAM SIDE — all citations verified against the code:
6. RetailPViewRenderer.DrawCellObjectLists (src/AcDream.App/Rendering/RetailPViewRenderer.cs:401-426) calls UseIndoorMembershipOnlyRouting() at :420 before every cell bucket; UseIndoorMembershipOnlyRouting (:439-450) clears clip routing for entities and env-cells, with the comment at :441-447 explicitly acknowledging retail's Render::viewconeCheck while implementing nothing in its place.
7. WbDrawDispatcher.EntityPassesVisibleCellGate (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1816-1835) is pure ParentCellId-vs-visibleCellIds membership. The AABB frustum cull at :662-666 exists BUT is bypassed for these buckets: the production call site DrawEntityBucket (RetailPViewRenderer.cs:460-477) passes neverCullLandblockId: ctx.PlayerLandblockId while setting the entry's LandblockId to the same value (:465-466), so `entry.LandblockId != neverCullLandblockId` is false and the frustum test is skipped. The divergence is therefore slightly STRONGER than claimed: indoor cell-bucket entities receive no geometric culling at all — only set membership + depth test.
8. No viewcone equivalent anywhere: grep for viewcone/ViewCone/view_cone across src/ hits only the two comments (RetailPViewRenderer.cs:371, :442). PortalVisibilityFrame.CellViews (the data a port would need) is consumed only by ClipFrameAssembler.cs:93, IndoorDrawPlan.cs:24, and a GameWindow debug dump (GameWindow.cs:9416) — never by an entity-sphere test. InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) is also pure membership.
9. Blast-radius premise verified: clipShells is true only for outdoor-eye roots (RetailPViewRenderer.cs:374-378 comment + gate at :378-380), so at indoor roots the shells draw unclipped AND the objects are ungated — depth test is the only mechanism hiding far-room objects, exactly as the claim states.
JUDGMENT: the divergence is real, not behaviorally-equivalent-elsewhere, and not already handled. Retail enforces the portal view on object lists twice (per-view sphere gate at draw time + BoundingType handed into DrawMeshInternal); acdream enforces it zero times for objects. Severity 'high' is fair: it is a visible artifact class (objects painting through walls wherever depth doesn't cover — unclipped indoor-root shells, anything drawn after a depth clear) plus unbounded interior overdraw, but not 'critical' since depth test masks the common case. The proposed port shape (CPU sphere-vs-CellView-edge-planes pre-draw test; defer PARTIALLY_INSIDE semantics until DrawMeshInternal's BoundingType handling is decompiled) matches the verified retail mechanism. Two citation-level corrections folded into the claim text: vtbl+0x64 is DrawObjCellForDummies (sort + forward to DrawObjCell at +0x60), and the viewconeCheck call is per-portal-view with a building_view filter and a force-flag override that is always false on this path.
- blastRadius: Statics/doors/NPCs in any flooded cell draw whole, relying solely on depth test to hide them. Wherever the shell is unclipped (all indoor roots, see indoor-shell-clip-disabled) or depth was cleared per slice, far-room objects paint through walls — the phantom-staircase artifact class and part of #114's 'see-through to neighbour rooms'. Also pure overdraw cost in dense interiors.
- retailEvidence: DrawCells Loop 3 (Ghidra 0x005a4840) sets Render::PortalList = cell->portal_view.data[num_view-1] then calls DrawObjCell (vtbl+0x64); the mesh path calls Render::viewconeCheck (Ghidra 0x0054c250) unconditionally from DrawMesh (xrefs 0x005a08e4 / 0x005a09a4), testing the drawing sphere against the near plane + portal_npnts view-edge planes installed by Render::set_view (pc:343750) and returning OUTSIDE to skip the mesh.
- acdreamEvidence: RetailPViewRenderer.DrawCellObjectLists calls UseIndoorMembershipOnlyRouting() before every bucket (RetailPViewRenderer.cs:420, :439-450 — clip routing explicitly cleared with a comment asserting retail uses viewcone checks); WbDrawDispatcher then gates only on cell membership (EntityPassesVisibleCellGate, WbDrawDispatcher.cs:1816-1835) + global frustum AABB (:662-666). No code anywhere evaluates an object sphere against the cell's view polygons.
- portShape: Port Render::viewconeCheck as a CPU pre-draw test: for each entity in a cell bucket, test its bounding sphere against the cell's CellView polygons' edge planes (the data already exists in PortalVisibilityFrame.CellViews); OUTSIDE -> skip the entity. PARTIALLY_INSIDE handling (retail per-poly clip vs draw-whole) needs the DrawMesh decompile first. This restores retail's second enforcement mechanism without hard-clipping characters (the doorway-slicing problem the comment correctly avoids).
### [HIGH] indoor-shell-clip-disabled (confirmed) — Shell clipping enabled only for outdoor-eye roots — indoor roots draw flooded shells whole
- correctedClaim: Claim stands as written. One precision refinement for the gap map: retail's DrawEnvCell carries a DrawnThisFrame stamp guard (Ghidra 0x0052c0c0/0x0052c0e0 vs m_nFrameStamp), so a cell reachable through multiple view slices draws its shell once, clipped to the FIRST slice's view polygon — not re-drawn per slice. acdream's per-slice re-render (RetailPViewRenderer.cs:388-393) is a slightly-more-generous union; the asymmetry divergence itself is unaffected.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. PView::DrawCells (Ghidra 0x005a4840, decompiled): the shell-draw loop (the bottom do/while over cell_draw_list in reverse, far-to-near) runs UNCONDITIONALLY for every cell with a drawing_bsp: per view slice it calls CEnvCell::setup_view(cell, sliceIndex) then the render-device virtual draw (vtbl+0x5c, one CEnvCell* arg = DrawEnvCell's exact signature). There is NO inside/outside branch anywhere in that loop. The only outside-gated block in DrawCells is the landscape + portal-mask block gated on outside_view.view_count != 0 — which is landscape drawing, not shell clipping. So "no inside/outside asymmetry in retail [shell clipping]" is confirmed at the decompile level.
2. CEnvCell::setup_view (Ghidra 0x0052c430): one-liner — Render::set_view(&this->portal_view[num_view-1]->view, sliceIndex). Confirms the per-slice accumulated portal view is installed before the cell draw.
3. Render::set_view (Ghidra 0x0054d0e0): installs the slice polygon globally — portal_vertex (slice poly vertices, each carrying an edge plane), portal_npnts, portal_inmask = (1<<(npnts+1))-1, plus the slice screen bbox xmin/xmax/ymin/ymax. Matches the claim's pc:343750 citation.
4. RenderDeviceD3D::DrawEnvCell (Ghidra 0x0059f170): non-built-mesh branch submits every cell-structure polygon with planeMask = -1 (0xffffffff) to Render::PolyList then flushes via vtbl+0x40 — confirming the pc:427922 anchor. The flush pipeline's clipper ACRender::polyClipFinish (Ghidra 0x006b6d00) reads Render::portal_npnts/portal_vertex (the set_view globals) and SutherlandHodgman-clips each submitted poly against the slice polygon's edge planes. The use_built_mesh branch is also covered: DrawEnvCell first calls Render::obj_view_set (Ghidra 0x0054b9b0), which transforms every edge plane of the CURRENT slice polygon into object space (portal_obj_plane[]) for the mesh path — the per-slice view is prepared and enforced on both branches. Retail clips shells to the per-slice aperture region for every root type.
One mechanical nuance (does not change the verdict): DrawEnvCell early-outs on CEnvCell::GetDrawnThisFrame (Ghidra 0x0052c0c0: m_current_render_frame_num == render_device->m_nFrameStamp; stamp bumped once in DrawCells before the shell loop), so a multi-slice cell's shell effectively draws ONCE, clipped to its FIRST view slice, rather than once per slice. The claim's wording "setup_view per view slice before each DrawEnvCell" is call-level accurate (the early-out is inside DrawEnvCell). acdream draws the shell once per slice (union of regions) — a minor, more-generous difference irrelevant to the claimed asymmetry.
ACDREAM SIDE — all four citations verified against the code:
1. RetailPViewRenderer.cs:104-105 — DrawEnvCellShells(..., clipShells: ctx.RootCell.IsOutdoorNode). Confirmed; comment at :96-103 explicitly documents the #114 scope-down (indoor roots stay unclipped after the first user gate's chopped-stairs/vanishing-walls/see-through findings).
2. RetailPViewRenderer.cs:378-380 and :396-398 — GL_CLIP_DISTANCE0..MaxPlanes enabled/disabled ONLY when clipShells is true. Confirmed.
3. RetailPViewRenderer.cs:452-458 — UseShellClipRouting still writes the per-cell slot routing unconditionally, but with the enables off the gl_ClipDistance writes in mesh_modern.vert:120 are ignored (GL semantics, documented at :361-363), so the routing is inert for indoor roots. Verified no enclosing enable leaks at the production call site: GameWindow.cs:7599-7663 calls DrawInside with RootCell = clipRoot (indoor roots have IsOutdoorNode=false; the outdoor-node root is built by OutdoorCellNode.cs:27); the outdoor terrain block (GameWindow.cs:7546-7587, EnableClipDistances at :7577) is skipped when clipRoot is non-null; and DrawRetailPViewLandscapeSlice — which runs inside DrawInside BEFORE the shell pass (RetailPViewRenderer.cs:93 vs :104) — ends with DisableClipDistances() (GameWindow.cs:9550). So indoor shell draws genuinely run with all clip planes disabled.
4. RetailPViewRenderer.cs:428-437 + :22-23 — GetCellSlicesOrNoClip falls through to the full-screen NoClipSlice (slot 0, planes empty = shader pass-all per mesh_modern.vert:122) when the assembler produced no slice. Confirmed, including the :367-369 note that the >8-plane scissor fallback is unimplemented.
DIVERGENCE REALITY: confirmed real, not behaviorally equivalent, not compensated elsewhere. The flood (PortalVisibilityBuilder + ClipFrameAssembler, run unconditionally at RetailPViewRenderer.cs:48-63) computes per-cell aperture regions for indoor roots too, but the indoor shell draw ignores them — exactly the "two gates disagree about the same shell" framing. DrawExitPortalMasks (:95) is a depth trick on exit apertures and does not constrain a flooded neighbor cell's geometry to its entry aperture; DrawCellObjectLists is unclipped by design in both modes (matching retail's mesh path, per :439-447). The blast-radius mapping to #114 is exact — the #114 charter and the user-gate findings are quoted in the code comment itself (:96-103), and #114 was filed in commit 6c9bbce as "indoor shell-clip region quality". The port shape in the claim matches the code reality: the mechanism exists and is live for outdoor roots; the gap is indoor region quality, after which the clipShells parameter can be deleted to restore retail's single unconditional rule. Severity "high" is appropriate.
- blastRadius: #114 directly (chopped stairs / vanishing inner walls / see-through at the meeting hall were the user-gate findings that forced the scope-down). Two gates disagree about the same shell: the flood says 'visible through THIS aperture region', the indoor draw ignores the region. Combined with object-lists-skip-portal-view-gate it is the core of 'indoor world feels right'.
- retailEvidence: Retail always clips shells: DrawCells Loop 2 (Ghidra 0x005a4840) runs CEnvCell::setup_view(cell, i) per view slice before each DrawEnvCell, and DrawEnvCell submits every cell polygon with planeMask=0xffffffff (pc:427922) through the set_view planes (pc:343750). There is no inside/outside asymmetry in retail.
- acdreamEvidence: RetailPViewRenderer.cs:104-105 passes clipShells: ctx.RootCell.IsOutdoorNode into DrawEnvCellShells; the GL_CLIP_DISTANCEi enables at :378-380 are skipped for interior roots, making the UseShellClipRouting slot writes (:452-458) inert (gl_ClipDistance writes are ignored when the enables are off, per the #113 comment :360-369). Cells without an assembler slice additionally fall through to a full-screen NoClipSlice (:428-437).
- portShape: Not a new mechanism — the machinery exists and is validated for outdoor roots. The port work is making indoor clip REGIONS pixel-exact (the #114 charter): fix the per-slice region quality (assembler slot fidelity, >8-plane fallback, slice ordering) until the indoor enables can be flipped on, then delete the clipShells parameter so one rule covers both roots.
### [HIGH] particles-third-gate-tier (UNVERIFIED (verifier hit token limit)) — Particles are gated by a third, weaker mechanism (scissor rectangle only) and several emitter classes are dropped entirely on the unified path
- blastRadius: 'Particles-through-walls': a cell's particles draw anywhere inside the slice's NDC AABB rectangle (a superset of the aperture), and cells without a slice get a FULL-SCREEN NoClipSlice scissor. Additionally on every unified-path frame (the normal in-world case): Scene-pass emitters with AttachedObjectId==0 are never drawn (both production filters require !=0), and LiveDynamic entities' particles are never drawn — silent VFX loss vs the legacy branch which drew both.
- retailEvidence: Retail draws particles through the same single view product as everything else — particle emitters hang off objects in the cell's object list, drawn inside DrawObjCell under Render::PortalList = the cell's portal_view (DrawCells Loop 3, Ghidra 0x005a4840), gated by the same viewconeCheck mesh path (0x0054c250). There is no separate scissor-rectangle tier.
- acdreamEvidence: DrawRetailPViewCellParticles: clip distances disabled, BeginDoorwayScissor(slice.NdcAabb) only, filter AttachedObjectId != 0 (GameWindow.cs:9568-9576); NoClipSlice fallback for slot-less cells (RetailPViewRenderer.cs:428-437) makes that scissor full-screen. Outdoor attached particles same pattern (:9519-9530). The only path drawing AttachedObjectId==0 Scene emitters is the clipRoot==null block (:7846-7868) which is unreachable in normal play; LiveDynamic particles have no draw site on either branch.
- portShape: Route particles through the object-list gate once viewconeCheck lands: an emitter draws iff its owning entity passed the viewcone test for its cell (no scissor tier needed; particle.vert has no gl_ClipDistance so a sphere-level CPU gate is the faithful shape). Re-home unattached and LiveDynamic emitters into the partition buckets so they draw under the same rule.
### [MEDIUM] dual-live-visibility-computations (confirmed) — Two visibility computations run per frame: the ACME BFS (CellVisibility) and the retail flood (PortalVisibilityBuilder)
- correctedClaim: Confirmed as stated, with one refinement: the doc-vs-callsite contradiction (CellVisibility.cs:343-347 vs GameWindow.cs:7313) is real but the doc comment is the likely-stale half (it predates the Phase-W move of the render root to the VIEWER cell, where viewer-eye + viewer-root is the consistent pairing); either way the position argument is behaviorally inert because it only affects the unread VisibleCellIds/HasExitPortalVisible fields. The proposed one-liner replacement (`bool cameraInsideCell = viewerRoot is not null;`) is proven exactly equivalent: TryGetCell success implies non-empty _cellLookup, and GetVisibleCellsFromRoot unconditionally returns CameraCell = root.
- verifier notes: RETAIL SIDE (re-derived from Ghidra decompile, not BN pseudo-C): (1) SmartBox::RenderNormalMode decompiled at 0x00453aa0 — contains NO visibility graph traversal: outdoor viewer (objcell_id & 0xffff < 0x100) goes straight to LScape::draw; indoor viewer dispatches vtable +0x48 with this->viewer_cell (= RenderDeviceD3D::DrawInside); seen_outside only gates the LScape::update_viewpoint(get_outside_cell_id) sky/lighting viewpoint. (2) PView::DrawCells decompiled at 0x005a4840 — pure consumer: reads this->outside_view.view_count, iterates this->cell_draw_list/cell_draw_num (filled by ConstructView), draws; zero portal recursion, zero ConstructView calls. (3) PView::ConstructView confirmed at pc:433750 (0x005a57b0, CEnvCell variant) with the CBldPortal variant at pc:433827; DrawInside calls ConstructView(this, cell, 0xffff) once at pc:433817. So retail runs exactly ONE per-frame visibility walk (ConstructView flood), consumed by DrawCells — the claimed retail picture is accurate.
ACDREAM SIDE (all citations re-read): GameWindow.cs:7313 calls _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos); :7314 `bool cameraInsideCell = visibility?.CameraCell is not null` is the ONLY read of `visibility` (grep of \bvisibility\b in GameWindow.cs: no other code reads). The BFS body GetVisibleCellsFromRoot is CellVisibility.cs:455-515 (class doc :207 'Ported faithfully from ACME's EnvCellManager.cs'), allocating per call: VisibilityResult (:457, whose VisibleCellIds HashSet inits at :184), visited HashSet (:458), Queue (:459) — runs only on indoor frames (root==null returns null immediately at :350-351). VisibilityResult.VisibleCellIds (:184, written :462/:509) and HasExitPortalVisible (:190, written :479) have ZERO readers in src (the src hits for 'VisibleCellIds' are the unrelated Core physics CellPhysics.VisibleCellIds in CellDump/CellTransit/PhysicsDataCache; PortalVisibilityBuilder.cs:73 is a comment). LastVisibilityResult (:234) also has no src reader outside CellVisibility.cs. cameraInsideCell feeds exactly two places: UpdateSkyPes at :7344 gated on _options.EnableSkyPesDebug (:7343, debug-only), and the EmitRenderSignatureIfChanged probe arg at :7899 (formatted 'camIn=' at :9293). Crucially I checked the sky gate at :7423 — it is `viewerRoot is null || rootSeenOutside`, NOT cameraInsideCell (the :7419-7422 mentions are comments only), so no hidden draw-decision consumer. The real retail flood runs separately: RetailPViewRenderer.DrawInside → PortalVisibilityBuilder.Build at RetailPViewRenderer.cs:48, invoked per frame from GameWindow.cs:7604 — so on indoor frames TWO independent portal-graph traversals execute, vs retail's one.
PORT SHAPE VERIFIED zero-behavior-change: viewerRoot comes from _cellVisibility.TryGetCell (:7311), which reads the same _cellLookup the BFS guards on (CellVisibility.cs:286-287), so viewerRoot non-null ⇒ _cellLookup non-empty ⇒ ComputeVisibilityFromRoot returns non-null with CameraCell=root unconditionally (:457). Hence cameraInsideCell ≡ (viewerRoot is not null) exactly. Only production caller of any BFS entry point is GameWindow.cs:7313 (ComputeVisibility/GetVisibleCells have no src callers — test-compat only per :311-316/:425-431).
CONTRACT-CONTRADICTION sub-claim: literally true — the param doc (CellVisibility.cs:343-347) says 'Should be the player/physics position (stable inside the cell), not the chase-camera eye'; the site passes viewerEyePos = camPos (:7306, :7313). One nuance the claim doesn't note: the doc is arguably the stale half (written in the Stage-3 player-root era; post-W-V1 the root IS the viewer cell, so viewer-eye + viewer-root is the internally consistent pairing). Moot either way: cameraPos only influences the portal-side test (:493-506) which affects only the unread VisibleCellIds/HasExitPortalVisible — never CameraCell. Severity 'medium' (one-gate violation + per-indoor-frame CPU/alloc + drift trap, no pixel disagreement today) is fair.
- blastRadius: No pixel disagreement TODAY — the BFS result is consumed only as a non-null bool. But it is a literal one-gate violation (the rule that cost the 2026-05-25 week), live CPU + per-frame allocation (HashSet/Queue/VisibilityResult per indoor frame), and a drift trap: a future reader wiring VisibleCellIds back into a draw decision reintroduces the three-gate era. The call also contradicts its own contract (doc says pass the stable player position; the site passes the jittering chase eye).
- retailEvidence: Retail computes visibility once: ConstructView (pc:433750) is the only per-frame visibility walk; DrawCells (Ghidra 0x005a4840) only consumes it. Nothing in RenderNormalMode runs a second graph traversal.
- acdreamEvidence: GameWindow.cs:7313 var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos) -> full BFS (CellVisibility.cs:455-515); :7314 is the sole consumer (bool). VisibleCellIds/HasExitPortalVisible unread in src (CellVisibility.cs:184/190 produced :462/:479/:509). cameraInsideCell feeds only debug UpdateSkyPes (:7343-7344) and the render-sig probe (:7899). The real flood runs separately at RetailPViewRenderer.cs:48.
- portShape: Replace :7313-7314 with `bool cameraInsideCell = viewerRoot is not null;` and demote CellVisibility's BFS methods (ComputeVisibility/ComputeVisibilityFromRoot/GetVisibleCells*) to the test assembly or delete; keep the class as the cell registry (rename candidate: CellRegistry). Zero behavior change by construction.
### [MEDIUM] landscape-redrawn-per-outside-slice (adjusted) — The whole landscape (sky + terrain + scenery + weather) is re-drawn once per OutsideView slice; retail draws it once
- correctedClaim: acdream re-runs the ENTIRE landscape pipeline (sky + SkyPre/Post particles + full terrain dispatch + outdoor entities + scene particles + weather) once per OutsideView slice (RetailPViewRenderer.cs:223-232 -> GameWindow.cs:9465-9551), where retail runs the landscape PASS exactly once (PView::DrawCells 0x005a4840: PortalList=&outside_view; LScape::draw once; sky/terrain/weather bundled at 0x00506330) and handles multi-polygon outside_view per PART: the visibility pass loops set_view over views to mark blocks (draw_check_blocks via 0x0050603e), and individual parts visible in multiple views ARE re-drawn once per visible view, clipped to that view (RenderDeviceD3D::DrawMesh 0x005a0860 calls DrawMeshInternal per non-OUTSIDE view). The faithful port is therefore NOT "draw each part exactly once" but "one landscape pass whose fixed costs run once, with a per-part view loop (test per view, draw clipped per visible view)" — behaviorally approximable by uploading all OutsideView polygons as one multi-region clip set tested in-shader. The user-visible divergence is (1) N x full-pipeline fixed cost per frame indoors (terrain dispatch, sky dome, weather re-run per slice — retail re-touches only parts spanning multiple views), and (2) alpha double-composite confined to acdream's slice-overlap regions, which exist mainly because particle passes draw with clip distances disabled (AABB scissor only, GameWindow.cs:9489/9518/9537) and because >8-plane slices fall back to AABB-only clipping (ClipFrameAssembler.cs:114-119) — exact-plane-clipped terrain/sky slices produce the same image as a single draw where slices are disjoint.
- verifier notes: RETAIL side re-derived from Ghidra (not BN pseudo-C): (1) PView::DrawCells @ 0x005a4840 — gated on `(this->outside_view).view_count != 0`, sets `Render::PortalList = &this->outside_view` and calls `LScape::draw(this->lscape)` EXACTLY ONCE; no loop over view polygons surrounds it. (2) LScape::draw @ 0x00506330 — the single call bundles sky (`GameSky::Draw(sky,0)`), per-landblock terrain draw (`block_draw_list` loop, each block drawn once if `in_view != OUTSIDE` via render-device vtable+0x50), and weather (`GameSky::Draw(sky,1)` gated on `weather_enabled`). (3) LScape::draw_check_blocks (decompiled via 0x0050603e) — the VISIBILITY pass loops `Render::set_view(&PortalList->view, i)` over all view_count polygons to mark blocks in-view (OR across views); the block DRAW after it runs once per block. (4) CRITICAL NUANCE the claim missed: RenderDeviceD3D::DrawMesh @ 0x005a0860 — when PortalList != null it loops view_count, `set_view` per view, `viewconeCheck(drawing_sphere)`, and calls `DrawMeshInternal` once PER non-OUTSIDE view. So retail DOES multi-draw an individual part once per view polygon it is visible in (each draw clipped to that view); "retail draws it once" is true at the PASS level only. ACDREAM side verified: RetailPViewRenderer.cs:219-232 `DrawLandscapeThroughOutsideView` loops `foreach (var slice in clipAssembly.OutsideViewSlices)` calling `SetTerrainClip(slice.Planes)` + `ctx.DrawLandscapeSlice(...)` per slice. The callback (wired at GameWindow.cs:7624-7634 with a per-frame-constant renderSky) is GameWindow.DrawRetailPViewLandscapeSlice :9465-9551, which per invocation runs: sky :9486, SkyPreScene particles :9490-9492, FULL terrain dispatch :9496, outdoor entities :9503-9512, scene particles :9519-9530, weather :9533-9536, SkyPostScene particles :9538-9540 — i.e., the entire landscape pipeline re-runs per slice. N>1 is real in production: ClipFrameAssembler.cs:134-164 emits one ClipViewSlice per `pvFrame.OutsideView.Polygons` entry with no union step, and PortalVisibilityBuilder.cs:279 appends each exit portal's clipped region into OutsideView (one polygon — or several, since clipping can split — per visible exit aperture). Two acdream-specific aggravators beyond the claim: (a) the <=8-plane budget fallback (ClipFrameAssembler.cs:114-119, 153-158) gives a slice slot 0 with EMPTY planes — that slice clips only to its NDC-AABB scissor, inflating overlap with neighbouring slices; (b) all particle passes within the slice draw with clip distances DISABLED (GameWindow.cs:9489, 9518, 9537), so particles are clipped only by the slice's AABB scissor (:9477) — blended particle content genuinely composites N times in AABB-overlap regions. Tempering of the blast radius: for plane-clipped slices, terrain/sky fragments are clipped to the exact slice polygon, so where slices do not overlap the N draws produce the same image as one draw (the cost is N x dispatch, not double-bright); retail's per-part view loop has the same overlap exposure in principle, but clips to the exact view polygon, whereas acdream's AABB fallbacks and unclipped particles create overlap retail would not have. The double-bright-rain-through-two-doorways framing therefore holds for particles/AABB-fallback slices, but is overstated for the plane-clipped terrain/sky case. Severity medium is fair; the dominant real effect is N x full-pipeline fixed cost plus particle/fallback double-composite; the #108 contribution (scenery re-drawn per slice under frame-to-frame-changing clips) remains plausible but unproven.
- blastRadius: With N visible exit apertures the terrain/sky/scenery/weather pipeline runs N times (N x full terrain dispatch, N x sky). Alpha-blended passes (weather rain cylinder, sky cloud layers) composite N times where slices overlap -> double-bright rain/sky through two doorways. Plausible contributor to #108 (grass-sweep: scenery re-drawn per slice under different clip planes/scissor as slices change shape frame to frame) and to indoor-frame FPS dips.
- retailEvidence: DrawCells Loop 0 (Ghidra 0x005a4840): Render::PortalList = &this->outside_view; LScape::draw(this->lscape) is called exactly ONCE — the multi-polygon outside_view is handled by the viewcone machinery per drawn part, not by re-running the landscape per polygon.
- acdreamEvidence: RetailPViewRenderer.DrawLandscapeThroughOutsideView loops `foreach (var slice in clipAssembly.OutsideViewSlices)` calling ctx.DrawLandscapeSlice per slice (RetailPViewRenderer.cs:223-232); each callback runs the FULL sky + terrain + outdoor-entity + weather sequence (GameWindow.DrawRetailPViewLandscapeSlice :9465-9551).
- portShape: Draw the landscape once under the union region: either upload all OutsideView polygons as the multi-region the shaders test (requires >1 region per pass in the UBO/SSBO scheme), or scissor to the union AABB + plane-clip per geometry against its best-fit slice. The faithful shape is retail's: one landscape pass, per-part viewcone test against outside_view.
### [MEDIUM] flood-convergence-heuristics (UNVERIFIED (verifier hit token limit)) — The flood carries acdream-only convergence heuristics (re-enqueue cap, NDC snap-dedup, eye-inside-opening rescue) with frame-to-frame membership effects at edges
- blastRadius: #109 far-door oscillation is the natural symptom: at grazing/far apertures the heuristics (MaxReprocessPerCell cap binding, CanonicalKey snap collapsing or admitting a drifted region, the 1.75 m eye-rescue toggling) flip a far cell in/out of OrderedVisibleCells across frames -> its shell/objects strobe. NOT proposing to revert the keep-listed flood port — this is the residual divergence inventory inside it.
- retailEvidence: ConstructView's termination is watermark-based: AddViewToPortals (pc:433446) compares the last-incorporated view watermark vs current view_count and handles growth in place; cells append to cell_draw_list once per pop (pc:433783). Retail has no per-cell pop cap, no NDC grid snap-dedup, and no eye-distance rescue constant — its 3D homogeneous clip (GetClip finish=1 -> polyClipFinish, pc:702749 per PortalProjection.cs header) makes those unnecessary.
- acdreamEvidence: PortalVisibilityBuilder.cs:51 MaxReprocessPerCell=16 (re-enqueue allowed when a popped cell's view grows, :348-354 — coexisting with the enqueue-once `queued` set :109); PortalView.cs:97 DedupGridNdc=1e-3 snap + collinear canonicalization :115-164; EyeStandingPerpDist=1.75 m rescue PortalVisibilityBuilder.cs:258-267/:869; MinW=0.05 / EyePlaneW=1e-4 PortalProjection.cs:182-188.
- portShape: Confirm retail's exact re-enqueue semantics in Ghidra (open question below), then converge: if retail never re-enqueues, drop the cap+re-enqueue and propagate late growth in place exactly as AddViewToPortals does; keep the snap-dedup only as an assertion (it should become unnecessary once the region pipeline is drift-free). Each heuristic removed shrinks the #109 oscillation surface.
### [MEDIUM] exit-portal-mask-pass-dormant (confirmed) — Retail's exit-portal poly pass (DrawCells Loop 1) is wired as a callback that no production caller sets
- correctedClaim: Claim stands as written, with two refinements: (1) retail's exit-portal poly pass (and the LScape draw + portalsDrawnCount-gated z-clear it pairs with) only executes when outside_view.view_count != 0 — i.e. on frames where any outside view exists; (2) the pass writes each exit-portal polygon's REAL projected depth (maxZ2=6: z-write on, DEPTHTEST_ALWAYS, alpha=0 color-invisible) and is also what arms the next frame's z-clear via portalsDrawnCount++ — so acdream is missing a coupled pair (mask + armed clear), not just the mask. acdream's callback is dead in the entire worktree (not even tests assign it).
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN pseudo-C):
1. PView::DrawCells (Ghidra decompile @ 0x005a4840) matches the claim exactly. Inside `if (outside_view.view_count != 0)`: Render::useSunlightSet(1) → LScape::draw → D3DPolyRender::FlushAlphaList → frameStamp++ → conditional z-clear `if (forceClear || portalsDrawnCount != 0) { portalsDrawnCount = 0; vtbl+0x2c(4, &RGBAColor_Black, 1.0f); }` → Loop 1: for each cell in cell_draw_list (reverse), if structure->drawing_bsp != null, for each setup_view slice (`CEnvCell::setup_view(cell, i)`), for each portal with `other_cell_id == -1` (stride 0x18): `D3DPolyRender::DrawPortalPolyInternal(portal->portal, false)`. Then useSunlightSet(0)/restore_all_lighting → Loop 2 shell pass (vtbl+0x5c per slice) → Loop 3 object lists (vtbl+0x64). So the exit-portal poly pass is AFTER LScape + the portalsDrawnCount-gated z-clear and BEFORE the shell pass, as claimed.
2. D3DPolyRender::DrawPortalPolyInternal (Ghidra @ 0x0059bc90), param_2=false path selects global `maxZ2`, whose static initializer is 6 (pc:1105964 `00820e14 int32_t maxZ2 = 0x6`; maxZ1 = 7 at pc:1105965); no runtime writes found. Decoding flags=6 (0b110) against the decompile: bit0=0 → vertex z = real projected z/w (not the 0.99999994 far-plane constant); bit1=1 → vertex alpha forced to 0 (`~(flags<<30) & 0x80000000` = 0) under SetBlendFunction(SRCALPHA, INVSRCALPHA) → color-invisible; bit2=1 → z-write ENABLED; depth mode = DEPTHTEST_ALWAYS; SetStageTexture(0,null); SetCullMode(NONE); DrawPrimitiveUP(TRIANGLEFAN). So Loop 1 is precisely a color-invisible, depth-always, z-writing draw of each exit-portal polygon at its REAL depth — "draw-the-portal-poly z machinery" as claimed. Additionally `portalsDrawnCount++` fires only on the param_2=false path, i.e. this pass is what ARMS the next frame's z-clear — the clear and the poly pass are a coupled pair; acdream reproduces neither half. Xrefs (Ghidra function_xrefs): callers are DrawCells (005a49b7), PView::DrawPortal (005a5b7c), PView::ConstructView (005a5a7b) — consistent with this being PView-internal machinery.
ACDREAM SIDE — all citations check out:
3. RetailPViewRenderer.cs:325-343 — DrawExitPortalMasks orchestration exists (reverse OrderedVisibleCells, per GetCellSlicesOrNoClip slice — mirrors retail's reverse cell_draw_list per-slice loop) but no-ops at :331-332 when ctx.DrawExitPortalMasks is null. It is invoked at the retail-faithful position in both flows: DrawInside at :95 (after DrawLandscapeThroughOutsideView at :93, before DrawEnvCellShells at :104) and DrawPortal at :204.
4. No production caller sets the callback — verified STRONGER than claimed: `grep 'DrawExitPortalMasks\s*='` across the entire worktree returns ZERO matches (not even tests assign it; only the nullable declarations at RetailPViewRenderer.cs:497/534/558). The DrawInside production context (GameWindow.cs:7604-7663) assigns RootCell/NearbyBuildingCells/DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles/EmitDiagnostics etc. but never DrawExitPortalMasks; the DrawPortal context (GameWindow.cs:7780-7798) likewise omits it. Dead plumbing confirmed.
5. The substitute machinery is as claimed: ClearDepthSlice at GameWindow.cs:7644-7652 — for indoor roots a SCISSORED full depth clear over each OutsideView slice's NDC AABB (invoked per slice at RetailPViewRenderer.cs:234-235, after all landscape slices draw); for the outdoor node `null` (no depth op at all), with the comment at GameWindow.cs:7635-7643 explicitly documenting the full-buffer-wipe hazard the suppression works around. Stencil claim verified: grep for Stencil in src/ hits only the frame-start global Clear (GameWindow.cs:7156), a diagnostics readout (:9651), save/restore helpers (GLStateScope.cs:112-181, GLHelpers.cs:239), and framebuffer attachment plumbing (ManagedGLFrameBuffer.cs) — no production stencil pass.
IS THE DIVERGENCE REAL? Yes. Retail's depth story at exit portals is ONE path regardless of viewer location: (armed) full z-clear + per-exit-portal real-z color-invisible poly write. acdream substitutes two different non-equivalent behaviors branched on IsOutdoorNode: an AABB-footprint clear-to-FAR (wrong polarity — retail writes the doorway's real z, acdream erases to far; and an axis-aligned superset of the portal poly) indoors, and nothing outdoors. No other acdream mechanism is equivalent (the gl_ClipDistance shell clip is the Render::set_view geometric-plane mechanism, not the z-mask). The claim's port-shape note is also correct: GameWindow.cs:7644 is itself an indoor/outdoor branch retail does not have, so the faithful port (depth-only exit-portal quads per slice, plus the portalsDrawnCount-armed z-clear) would unify it.
REFINEMENTS (not refutations): (a) retail's whole Loop-1 block — including LScape::draw and the z-clear — is gated on `outside_view.view_count != 0`; in a fully sealed interior with no outside view none of it runs, so the pass only matters on frames where outside is visible (which is exactly the doorway/#109 regime). (b) DrawPortalPolyInternal also skips degenerate portal polys whose vertices all sit at x or y = ±12.0 (cell-boundary extent quads). (c) Severity "medium" is fair — it is a depth-correctness artifact class at door apertures plus a workaround-shaped branch, not a top-level invariant break on its own.
- blastRadius: acdream substitutes a scissored full depth-clear per slice (indoor roots) / no clear (outdoor node) for retail's draw-the-portal-poly z machinery. Depth relationships at doorways therefore differ from retail by construction — candidate contributor to far-door artifacts (#109's visual component) and to the outdoor-node decision to suppress the clear entirely (GameWindow.cs:7635-7644 comment documents the wipe hazard the suppression works around).
- retailEvidence: DrawCells Loop 1 (Ghidra 0x005a4840): per cell, per setup_view slice, D3DPolyRender::DrawPortalPolyInternal(portal->portal, false) for every portal with other_cell_id == -1, executed AFTER the LScape draw + the portalsDrawnCount-gated z-clear (vtbl+0x2c flag 4) and BEFORE the shell pass.
- acdreamEvidence: RetailPViewRenderer.DrawExitPortalMasks no-ops when ctx.DrawExitPortalMasks is null (:331-332); neither production context initializer assigns it (GameWindow.cs:7604-7663 DrawInside ctx, :7780-7798 DrawPortal ctx). No production stencil use exists (only save/restore helpers in GLStateScope.cs/GLHelpers.cs/ParticleBatcher.cs).
- portShape: Either implement the retail pass (draw exit-portal quads depth-only per slice, replacing the scissored glClear trick and the outdoor-node suppression) or delete the dead callback plumbing. The faithful port is the former; it also unifies the indoor/outdoor depth story that currently branches at GameWindow.cs:7644.
### [MEDIUM] legacy-outdoor-branch-remnant (adjusted) — A second full render path (clipRoot == null block) survives with different gates than the unified path
- correctedClaim: A second, separately-coded render path (the clipRoot == null block, GameWindow.cs:7726-7831) survives with gates that differ from the unified DrawInside path — CONFIRMED on the acdream side in every cited respect. The retail premise must be corrected, however: retail does NOT route every in-world frame through DrawInside(viewer_cell). Ghidra 0x453aa0 (SmartBox::RenderNormalMode) shows an explicit top-level branch on (viewer.objcell_id & 0xffff) < 0x100: outdoor viewers go straight to LScape::draw (after Render::set_default_view + useSunlightSet(1)), and only indoor viewers go through vtable+0x48 RenderDeviceD3D::DrawInside PView::DrawInside PView::DrawCells (0x005a4840), whose outside_view block (gated on outside_view.view_count != 0, with Render::PortalList = &outside_view) is where LScape::draw appears for indoor frames. The invariant that actually supports this divergence is narrower but still decisive: retail's two entries funnel into ONE shared drawing pipeline (the same LScape::draw building DrawPortal DrawCells machinery) with identical gates the outdoor entry is the degenerate full-screen-view case and retail's "viewer cell unknown" input (objcell_id == 0 satisfies < 0x100) lands in that SAME pipeline. acdream's analog of cell-unknown (viewerCellId == 0 clipRoot null) instead lands in a hand-written second pipeline whose gates diverge from the unified path in at least three verified ways: (1) indoor-parented entities dat statics AND live dynamics with an indoor ParentCellId are dropped wholesale by Partition over an empty visible set (GameWindow.cs:7732-7734 + InteriorEntityPartition.cs:35-44,67-68), recovered only partially by the 1-ring DrawPortal look-in (:7748-7811); (2) unattached scene particles (AttachedObjectId == 0) DO draw via the unfiltered global Scene-pass call (:7862-7867, reachable because clipAssembly is always null in this branch), whereas the unified path's only Scene-pass particle draws both filter to AttachedObjectId != 0 (:9523-9529, :9570-9576) and never draw unattached emitters; (3) a raw _wbDrawDispatcher.Draw fallback with no cell gating at all when _interiorRenderer is null (:7827-7831). Reachability as claimed: clipRoot == null viewerRoot == null AND viewerCellId == 0 (OutdoorCellNode.Build never returns null, OutdoorCellNode.cs:23-30; _outdoorNode built whenever viewerCellId != 0, GameWindow.cs:7458-7482), and viewerCellId falls back to playerRoot?.CellId ?? 0u under legacy/debug cameras (:7301-7305) so pre-spawn and legacy-camera-outdoors frames land here, and any future regression that zeroes the viewer cell silently lands here too. The port shape stands: shrink the null branch to the login/pre-spawn sky-only minimum (K-fix1) and route every in-world frame through DrawInside, after relocating the unattached-particle draw into the unified path.
- verifier notes: RETAIL re-derivation (Ghidra MCP, 127.0.0.1:8081, per the prefer-Ghidra rule): (1) Decompiled SmartBox::RenderNormalMode @ 0x453aa0 it contains an explicit if/else on bVar4 = ((viewer.objcell_id & 0xffff) < 0x100). True (outdoor) branch: LScape::update_viewpoint, Render::update_viewpoint, Render::set_default_view, Render::useSunlightSet(1), LScape::draw no PView, no DrawInside. False (indoor) branch: optional LScape::update_viewpoint(Position::get_outside_cell_id) when seen_outside, then (*(render_device->vtbl+0x48))(this->viewer_cell). (2) Decompiled the vtable target's body: RenderDeviceD3D::DrawInside(CEnvCell*) @ containing 0x0059f0d6 is a one-line wrapper calling PView::DrawInside(indoor_pview, cell); its CEnvCell* parameter type itself shows the indoor-only dispatch. (3) Decompiled DrawCells @ 0x005a4840 — it is PView::DrawCells; first block gated on (this->outside_view).view_count != 0 does Render::useSunlightSet(1); Render::PortalList = &this->outside_view; LScape::draw(this->lscape) — confirming the claimed 'LScape::draw is INSIDE DrawCells Loop 0' for the indoor flow. (4) function_xrefs?name=DrawCells: called only from PView::DrawInside (0x005a595b) and PView::DrawPortal (0x005a5b53). Conclusion: the claim's retail sentence 'RenderNormalMode -> DrawInside(viewer_cell) every in-world frame; there is no alternate outdoor pipeline' is an overstatement — exactly the branch-flattening error class this project has been burned by — but the corrected retail invariant (one shared pipeline, identical gates, unknown-cell input degrades into it) still supports the divergence, arguably more sharply: retail's fallback cannot diverge in gates because it IS the normal pipeline; acdream's can and does. ACDREAM verification (all by reading production code): GameWindow.cs:7497 (clipRoot = viewerRoot ?? _outdoorNode); :7458-7482 (_outdoorNode rebuilt per frame, only when viewerRoot is null && viewerCellId != 0); OutdoorCellNode.cs:23-30 (Build always returns a LoadedCell — never null); GameWindow.cs:7301-7305 (viewerCellId = chase camera ViewerCellId only when _playerMode && _retailChaseCamera != null && CameraDiagnostics.UseRetailChaseCamera, else playerRoot?.CellId ?? 0u — legacy/debug camera with player outdoors gives 0); :7726-7831 (the else block: Partition over cleared _outdoorRootNoCells at :7732-7734; sigOutdoorRootObjectCount/outdoor bucket draw :7735-7746; 1-ring candidate gather + DrawPortal look-in :7748-7811; LiveDynamic fallback :7813-7823; raw _wbDrawDispatcher!.Draw fallback :7827-7831). InteriorEntityPartition.cs:61-72 — AddByCellOrOutdoor silently returns (drops the entity from ALL buckets) when the cell id is indoor (low word >= 0x100, != 0xFFFF) and not in the visible set; with the empty set every indoor-parented entity is dropped, including ServerGuid != 0 live dynamics with indoor ParentCellId (:35-38), which are then also absent from the LiveDynamic fallback (it only holds null-ParentCellId entities, :39-40). Particles: the Scene-pass draw at :7846 is gated clipRoot is null; in that branch clipAssembly is provably always null (declared null :7501, only assigned :7665 inside the clipRoot != null branch), so the unfiltered global draw :7862-7867 runs — drawing AttachedObjectId == 0 emitters; the unified path's only Scene-pass particle sites are DrawRetailPViewLandscapeSlice :9523-9529 (filter AttachedObjectId != 0 && in _outdoorSceneParticleEntityIds) and DrawRetailPViewCellParticles :9570-9576 (filter AttachedObjectId != 0 && in _visibleSceneParticleEntityIds) — unattached scene emitters never draw there. Post-scene weather :7874-7889 likewise gated clipRoot is null; unified-path weather lives in the landscape slice :9533-9541. Side finding (not load-bearing): the filtered particle sub-branch at :7848-7858 (clipAssembly is not null inside clipRoot is null) is dead code by the same clipAssembly argument. Not verified here: the 'stricter membership rule' of the look-in — the claim explicitly defers it to a separate divergence entry. Severity medium and the proposed port shape are consistent with the evidence; no workaround is being proposed (the shape is delete-and-unify, matching the keep-listed Option A direction).
- blastRadius: Reachable pre-spawn and under legacy/debug cameras; any future regression that nulls the outdoor node silently lands here. Its gates differ from the unified path in at least three ways: indoor-parented entities are dropped wholesale (empty partition set), unattached scene particles DO draw (unlike the unified path), and its look-in uses a stricter membership rule (next entry). One-path violations of exactly this shape caused the original FLAP.
- retailEvidence: Retail has one render path: RenderNormalMode (0x453aa0) -> DrawInside(viewer_cell) every in-world frame; there is no alternate outdoor pipeline (LScape::draw is INSIDE DrawCells Loop 0, Ghidra 0x005a4840).
- acdreamEvidence: GameWindow.cs:7726-7831: global outdoor bucket via Partition(_outdoorRootNoCells empty set, …) (:7732-7746), 1-ring DrawPortal look-in (:7748-7811), LiveDynamic fallback (:7813-7823), raw _wbDrawDispatcher.Draw fallback when _interiorRenderer is null (:7827-7831); plus the only live sites of global scene particles (:7846-7868) and post-scene weather (:7874-7889).
- portShape: Shrink the null-clipRoot case to the login/pre-spawn minimum (sky only — the K-fix1 requirement) and route every in-world frame through DrawInside; delete the DrawPortal look-in and the global-bucket draw once the outdoor node is guaranteed non-null whenever a world exists. Move the unattached-particle draw into the unified path first (see particles entry) so deleting this branch loses nothing.
### [MEDIUM] drawportal-membership-rule-mismatch (UNVERIFIED (verifier hit token limit)) — DrawPortal and DrawInside use different drawable-cell membership rules in the same file
- blastRadius: The slot-keys rule silently drops cells whose view reduced to IsNothingVisible/slot-less — the documented grey-walls bug class (unsealed shells showing clear color), still live on the look-in path. Any frame that transitions between the two paths can flash a cell in/out.
- retailEvidence: Retail has one membership rule: every cell in cell_draw_list draws (DrawCells iterates cell_draw_num unconditionally, Ghidra 0x005a4840); a cell entered the list exactly by being popped in ConstructView (pc:433783).
- acdreamEvidence: DrawInside: drawableCells = new HashSet(pvFrame.OrderedVisibleCells) with the R1 comment naming the old slot-keys filter as the grey-walls bug (RetailPViewRenderer.cs:66-71). DrawPortal: drawableCells = new HashSet(clipAssembly.CellIdToSlot.Keys) (:182) — the exact filter the R1 fix removed.
- portShape: One-line alignment: DrawPortal adopts OrderedVisibleCells. Folds into deleting the legacy branch if that lands first.
### [MEDIUM] livedynamic-invisible-under-interior-roots (UNVERIFIED (verifier hit token limit)) — LiveDynamic entities (ServerGuid != 0, unresolved ParentCellId) draw only when the root is the outdoor node
- blastRadius: A just-spawned or not-yet-cell-resolved server entity is invisible for as long as the viewer is indoors (player standing in the inn when something spawns). The dispatcher's ResolveEntitySlot would also CULL them under active routing (by design), but here they are never even submitted — two layers agree to hide them with no retail basis.
- retailEvidence: Retail draws every object out of its cell's object list (DrawObjCell per cell, DrawCells Loop 3, Ghidra 0x005a4840) — an object always has a cell in retail (physics owns placement), so there is no 'unresolved' class to drop; the faithful behavior is resolve-then-draw, not drop.
- acdreamEvidence: The LiveDynamic draw is gated on clipRoot.IsOutdoorNode (GameWindow.cs:7716-7724); interior roots have no LiveDynamic submission site. ResolveEntitySlot returns ClipSlotCull for serverGuid != 0 with null ParentCellId while routing is active (WbDrawDispatcher.cs:449-450).
- portShape: Make cell resolution the fix, not the draw site: entities should carry a resolved ParentCellId by the time they render (membership pipeline), making the bucket empty by construction; until then, draw LiveDynamic under interior roots gated by the player-cell slice the way the outdoor node does, so nothing blinks out.
### [LOW] dual-frustum-implementations (UNVERIFIED (verifier hit token limit)) — Two frustum-cull implementations and two center/radius windows gate shells vs the objects inside them
- blastRadius: Margin disagreements only: a cell's shell (WbFrustum on the cell AABB at Prepare time, camera-LB-centred _nearRadius ring) and the SAME cell's statics (FrustumCuller per entity AABB, player-LB streaming window) can disagree for one frame at screen edges or at ring boundaries when camera and player straddle different landblocks -> shell-without-statics or statics-without-shell popping. Cheap consolidation; low urgency.
- retailEvidence: Retail has one viewcone: the planes Render::set_view installs (pc:343750) are the only cull surface both the shell submit (planeMask=0xffffffff, pc:427922) and the mesh check (viewconeCheck 0x0054c250) read.
- acdreamEvidence: EnvCellRenderer uses WbFrustum (TestBox :612, Intersects :642, updated from envCellViewProj at GameWindow.cs:7396) + a camera-centred radius ring (:581-588, center from camPos at GameWindow.cs:7390-7391); WbDrawDispatcher/terrain use FrustumPlanes+FrustumCuller built from the same VP (GameWindow.cs:7221; WbDrawDispatcher.cs:593-595/:662-666; TerrainModernRenderer.cs:216-218); the streaming window is player-centred (GameWindow.cs:7381-7387).
- portShape: Pick FrustumPlanes/FrustumCuller as the single implementation (already shared by terrain+entities), port EnvCellRenderer's Prepare to it, and key both radius windows off the same center. A conformance test comparing the two on random AABBs first (open question) de-risks the swap.
## OPEN QUESTIONS
- Does retail ever re-enqueue a cell into cell_todo_list after its first pop when its portal_view later grows, or is all late growth handled strictly in place via AddViewToPortals (pc:433446)? acdream keeps BOTH an enqueue-once set and a MaxReprocessPerCell=16 re-enqueue path (PortalVisibilityBuilder.cs:109 vs :348) — the faithful termination rule must be confirmed in Ghidra (decompile 0x005a5ab0-area ConstructView + AddViewToPortals) before porting it, since it directly affects #109.
- What does retail's DrawMesh do with viewconeCheck's PARTIALLY_INSIDE result — draw whole, or descend to per-poly clipping? Needed to size the viewcone port for object lists (decompile DrawMesh at 0x005a08e4 region).
- DrawCells Loop 3 sets Render::PortalList = cell->portal_view.data[num_view-1] — only the LAST entry. Is portal_view a stack whose last element is the accumulated union (so this is the full view), or does retail intentionally gate objects against only the most recent slice? Affects how acdream should aggregate CellViews for the object gate.
- Do any production PhysicsScript/content paths spawn Scene-pass particle emitters with AttachedObjectId==0? If yes, they are invisible on every unified-path frame today (the only draw site requiring ==0 is the unreachable legacy branch, GameWindow.cs:7857) — needs a live capture to size the blast radius before the particle re-route.
- Is the clipRoot==null branch ever reached in-world in player mode (can RetailChaseCamera.ViewerCellId be 0 while spawned)? Code reading says only pre-spawn/legacy cameras, but a runtime assertion/probe would prove the legacy branch is safe to shrink.
- Can WbFrustum and FrustumCuller actually disagree on the same AABB+VP in practice (both are Gribb-Hartmann variants)? A randomized conformance test would either justify immediate consolidation or document equivalence.
- Retail's z-clear in DrawCells Loop 0 is full-buffer but GATED on portalsDrawnCount/forceClear (Ghidra 0x005a4840) — what increments portalsDrawnCount, and does that gate reproduce acdream's outdoor-node 'no depth clear' decision naturally? Settling this defines the faithful replacement for the ClearDepthSlice scissor trick and its IsOutdoorNode suppression (GameWindow.cs:7644).