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>
237 lines
88 KiB
Markdown
237 lines
88 KiB
Markdown
# Area 5 — Culling and frame composition end-to-end (incl. #108 grass-sweep + #109 far-door oscillation)
|
||
|
||
## RETAIL
|
||
|
||
RETAIL FRAME COMPOSITION (all claims verified against Ghidra decompile and/or pc lines; vtable offsets verified against the RenderDevice vtable dump at pc:1037033-1037066 / 0x7e5500).
|
||
|
||
== 1. Top of frame: SmartBox::RenderNormalMode (Ghidra 0x00453aa0, pc:92635) ==
|
||
One function decides the whole frame. It computes two booleans: `viewer_is_outside` = (viewer.objcell_id & 0xffff) < 0x100 (low word under 0x100 means a LANDCELL, i.e. the camera is outdoors), and `can_see_outside` = viewer_is_outside OR viewer_cell->seen_outside (Ghidra 0x453aa0). Then:
|
||
- OUTSIDE: LScape::update_viewpoint(viewer cell id) + Render::update_viewpoint + Render::set_default_view + Render::useSunlightSet(1) + LScape::draw(lscape) — the landscape pass IS the frame.
|
||
- INSIDE: if seen_outside, LScape::update_viewpoint(Position::get_outside_cell_id(viewer)) keeps the landscape system centered; then Render::update_viewpoint + device->vtable+0x48(viewer_cell). Offset +0x48 in RenderDeviceD3D's vtable (0x7e5548 minus base 0x7e5500) is **DrawInside** — so the inside path is exactly DrawInside(viewer_cell). NOTE: there IS a top-level outside/inside branch in retail, but both arms drive the SAME machinery (LScape::draw is also what the indoor path calls for its outdoor view; building interiors are drawn by the same PView flood from inside the outdoor pass) — the project's "one path" rule is about DrawInside(viewer_cell) being the single indoor entry and the landscape being reached through it, which this confirms.
|
||
- After either arm: D3DPolyRender::FlushAlphaList(0.0) — the global delay-rendered alpha list ("Alpha=2, Translucent=4, ClipMap=8" registry text at 0x7e5648) flushes once more at end of scene.
|
||
|
||
== 2. The outdoor pass: LScape::draw (Ghidra 0x00506330, pc:267912) ==
|
||
Order inside the landscape pass:
|
||
(a) **Sky first**: GameSky::Draw(sky, 0). GameSky::Draw (Ghidra 0x00506ff0, pc:268704) draws the celestial CPhysicsObjs with zfar temporarily ×4 and SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=0) (0x507055-0x507063) — the sky always passes depth and never writes it, so everything later paints over it; restores DEPTHTEST_LESSEQUAL + zwrite=1 after (0x5070fc — this also confirms LESSEQUAL+write as the world default). Pass-0 (dome) runs unconditionally; pass-1 (weather cell, DrawObjCellForDummies(after_sky_cell)) is gated `SmartBox::is_player_outside() || pass==0` (0x507009) — i.e. **is_player_outside gates ONLY weather**, matching the project rule.
|
||
(b) **Block culling**: LScape::draw_check_blocks (Ghidra 0x00505f80, pc:267678). If Render::PortalList is non-null (set by the indoor path, see §4) it loops `Render::set_view(&PortalList->view, i)` over every accumulated portal view and merges results — landscape CULLING is done per portal-clipped view, not per global frustum. Per block it builds a grid of view-interval columns via Render::get_clip_height and calls Render::block_check(corner intervals, max_zval, min_zval) → block->in_view; then landcell_check (Ghidra 0x005050a0) repeats the column test per 8×8 landcell → cell->in_view (LOD blocks short-circuit to all-in).
|
||
(c) **Blocks far→near**: block_draw_list is built by LScape::get_block_order (Ghidra 0x00504c50): index 0 = the viewer's own block, then expanding rings outward — and LScape::draw iterates it from the END (`while (--i >= 0)`), i.e. farthest ring first, viewer block last. Painter's order at block granularity.
|
||
(d) **Per block: RenderDeviceD3D::DrawBlock (pc:430027 / 0x5a17c0)**, two sub-passes: pass A per cell sorts the cell's object parts (CShadowPart::insertion_sort) after UpdateObjCell; pass B per cell INTERLEAVES: DrawLandCell (thunk pc:427860 → ACRender::landPolysDraw(cell->polygons, 2) — terrain polys go through the software poly-clip pipeline, clipped against the CURRENT view incl. portal views) → DrawSortCell → FlushAlphaList(flush) per cell. So terrain, buildings, and objects are drawn cell-by-cell, not in global passes.
|
||
(e) **DrawSortCell (Ghidra 0x0059f140)**: building first (vtable+0x68 = DrawBuilding), then cell contents (vtable+0x60 = DrawObjCell → DrawPartCell → CShadowPart::draw per sorted shadow part, pc:429198-429220).
|
||
(f) Weather last: GameSky::Draw(sky, 1) after the block loop (0x506396), only if weather_enabled (and internally only if player outside).
|
||
|
||
== 3. Buildings + interior flood from outdoors: DrawBuilding → DrawPortal ==
|
||
RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:429282): sets outdoor_pview->outdoor_portal_list = building->portals, then TWO part draws: `CPhysicsPart::Draw(part, 1)` (PORTALS-ONLY pass) followed by `ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part, 0)` (SHELL pass, the constructed mesh via D3DPolyRender::DrawMesh 0x59d4a0). The portals-only pass reaches DrawMeshInternal (pc:427978 / 0x59f360) which calls BSPTREE::build_draw_portals_only(drawing_bsp, **1**) then (drawing_bsp, **2**) (0x59f3cc/0x59f3d9 — the only two call sites of that walk, modes 1 and 2 only). BSPPORTAL::portal_draw_portals_only (pc:326881 / 0x53d870) walks the drawing BSP by viewer plane-side and fires device->DrawPortal(in_portals[i], 1, mode) for every BSPPORTAL node's CPortalPoly.
|
||
PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895): flush queued polys, backup state, look up the CBldPortal, add_views(portal stab list), then ConstructView(CBldPortal, poly, 1, mode) (Ghidra-verified 0x005a59a0, pc:433827): hard plane-side gate of the VIEWER against the portal polygon's plane (epsilon F_EPSILON=2e-4) honoring CBldPortal::portal_side; GetClip clips the portal polygon against the current view to a portal-shaped sub-view; CEnvCell::GetVisible(other_cell_id); Render::copy_view pushes the clipped view onto the destination cell's portal_view stack; then `if (mode != 2) DrawPortalPolyInternal(poly, mode==1)`; `if (mode != 1)` recurse into the CEnvCell ConstructView = interior flood. Back in DrawPortal: on success `if (mode != 1) DrawCells(this, 1)` — the interior cells are drawn nested INSIDE this building's draw, through this aperture. On flood-fail, `if (mode == 3) DrawPortalPolyInternal(poly, false)` — mode 3 has no caller in the built-mesh path.
|
||
**Mode semantics (load-bearing, Ghidra-verified)**: mode 1 = draw the portal poly as a FAR-PLANE DEPTH PUNCH and do NOT flood; mode 2 = flood + DrawCells with NO portal poly. So per building portal the sequence is: punch the aperture's depth to the far plane, then draw the interior into the clean hole with normal LESSEQUAL depth. D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90, pc:424468): transforms the portal poly, software-clips it against the current view (polyClipFinish), then draws it with NO texture, alpha-test off, SRCALPHA/INVSRCALPHA blend, CULL_NONE, **SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite = flagbit2)**, z = ~1.0 (far plane, literal 0x3f7fffef) if flagbit0 else true z/w, vertex alpha forced 0 (invisible) when flagbit1 — flags from globals maxZ1=7 (param true → far punch) and maxZ2=6 (param false → TRUE-DEPTH invisible fence; also portalsDrawnCount++) (statics at pc:1105964-1105965; cliplandscape=1 pc:1106108; forceClear=0 pc:1366769).
|
||
|
||
== 4. The indoor pass: PView::DrawInside → ConstructView → DrawCells ==
|
||
RenderDeviceD3D::DrawInside thunk (Ghidra 0x0059f0d0) → PView::DrawInside(indoor_pview, viewer_cell) (Ghidra 0x005a5860, pc:433793): curr_view_push, add_views(cell stab list), positionPush, copy_view(full-screen 4-pt view onto the root cell), ConstructView(cell, 0xffff), DrawCells. indoor_pview is constructed with draw_landscape=1 and outdoor_pview with draw_landscape=0 (RenderDeviceD3D::Init 0x0059efb0, pc:427788-427814) — only the indoor PView accumulates an outside view and re-draws the landscape; the nested outdoor-side floods never do.
|
||
**ConstructView (0x005a57b0, pc:433750)**: resets outside_view.view_count=0, master_timestamp++, InitCell(root)+InsCellTodoList(root, 0.0), then loop: pop a cell from the todo list, append to cell_draw_list, ClipPortals, AddViewToPortals. InsCellTodoList (Ghidra 0x005a4f50) is an insertion sort DESCENDING by distance (verified: `if (param_2 < existing->dist) break` walking from the top) and the loop pops from the END — i.e. pops the NEAREST cell first, so **cell_draw_list is ordered near→far**.
|
||
**ClipPortals (0x005a5520, pc:433572)**: sets Render::PortalList = the cell's newest portal_view; for each view and each seen portal, GetClip clips the portal polygon against that view; a portal whose other_cell_id == 0xffffffff leads OUTSIDE → if draw_landscape && cliplandscape, Render::copy_view accumulates the clipped region into **this->outside_view** (the union of every doorway/window region that reaches outdoors, in screen space); a portal to another EnvCell pushes the clipped view onto that cell's portal_view (with OtherPortalClip 0x005a5400 re-clipping through non-exact-match paired portals). AddViewToPortals (0x005a52d0, pc:433446) enqueues newly-seen neighbor cells (InitCell + InsCellTodoList with their viewer distance).
|
||
**PView::DrawCells (0x005a4840, pc:432703)** — THE composition answer, four stages:
|
||
Stage 1 (only if outside_view.view_count > 0): Render::useSunlightSet(1); Render::PortalList = &outside_view; **LScape::draw** — the FULL landscape machinery (sky included) runs, with blocks culled per accumulated portal view and every terrain poly software-clipped to the exact outside_view polygons; D3DPolyRender::FlushAlphaList(0); frameStamp++; then **Clear(D3DCLEAR_ZBUFFER(4), color, z=1.0)** — the WHOLE depth buffer is cleared, gated on `forceClear || portalsDrawnCount != 0` (0x5a4893-0x5a48a9); then the **portal depth FENCE**: for every cell in cell_draw_list (reverse = far→near), per view (CEnvCell::setup_view), for every portal with other_cell == 0xffffffff: DrawPortalPolyInternal(portal_poly, 0) → an invisible DEPTHTEST_ALWAYS zwrite-on quad at the aperture's TRUE depth (maxZ2). Net effect: outdoor color exists only inside the exact clipped apertures; outdoor depth is then thrown away entirely; the fence re-establishes the doorway's depth so any interior geometry FARTHER than the doorway can never overdraw the outdoor view, while interior geometry nearer than it still can (correct).
|
||
Stage 2: useSunlightSet(0) + restore_all_lighting; reverse cell_draw_list (far→near): per view setup_view then device->DrawEnvCell (Ghidra 0x0059f170, pc:427879) — once per cell per frame (GetDrawnThisFrame guard); built-mesh path = SetStaticLightingVertexColors + D3DPolyRender::DrawMesh; legacy path submits ALL structure->polygons with planeMask=0xffffffff (pc:427922) into the software-clipped poly list. Cell shell geometry is therefore always software-clipped to the current portal view.
|
||
Stage 3: reverse cell_draw_list: Render::PortalList = the cell's newest portal_view, then DrawObjCellForDummies (pc:429177 / 0x5a0760): re-sort shadow parts, DrawObjCell → DrawPartCell → CShadowPart::draw per part. Per-mesh culling here is **Render::viewconeCheck (Ghidra 0x0054c250)**: the part's bounding sphere is tested against the viewer plane AND the planes of the CURRENT portal view (portal_npnts loop) — every object is culled against the portal-clipped view it is drawn through, but its polygons are NOT hard-clipped (BoundingType PARTIALLY_INSIDE just disables trivial-accept).
|
||
Stage 4: restore object scale, useSunlightSet(1).
|
||
|
||
== 5. Depth-state summary ==
|
||
World default: DEPTHTEST_LESSEQUAL + zwrite on (restored at 0x5070fc). Sky: ALWAYS + no write. Portal punch: ALWAYS + write, z=far (maxZ1=7). Portal fence: ALWAYS + write, z=true (maxZ2=6), invisible. Indoor frames partition depth by ONE full Z-clear after the outdoor stage plus per-aperture fences; outdoor building apertures are partitioned by per-portal far punches before each nested interior draw. There is no per-portal-slice scissor anywhere — exactness comes from the software polygon clipper (ACRender::polyClipFinish) which every terrain/cell/portal poly passes through.
|
||
|
||
## ACDREAM
|
||
|
||
ACDREAM FRAME COMPOSITION (file:line, worktree root C:/Users/erikn/source/repos/acdream/.claude/worktrees/thirsty-goldberg-51bb9b).
|
||
|
||
== Frame entry: GameWindow.OnRender (src/AcDream.App/Rendering/GameWindow.cs:7124) ==
|
||
(1) ClearColor=fog tint; DepthMask(true) asserted then Clear(COLOR|DEPTH|STENCIL) (GameWindow.cs:7141-7156). Frame-global CullFace(Back)+FrontFace(CW) (7162-7163). WbMeshAdapter.Tick (7178). Animations (7188).
|
||
(2) Viewpoint roots: lighting root = PLAYER cell (7291-7296); render root = VIEWER cell from RetailChaseCamera.ViewerCellId (7301-7313); renderSky = viewerRoot null || rootSeenOutside (7423).
|
||
(3) Outdoor-as-cell cutover: when the eye is outdoors, _outdoorNode = OutdoorCellNode.Build(viewerCellId) with nearby building cells gathered from Chebyshev<=1 landblocks (7458-7482); clipRoot = viewerRoot ?? _outdoorNode (7497). clipRoot is null only pre-spawn/legacy-camera.
|
||
(4) clipRoot == null safety path (7546-7587): sky first via SkyRenderer.RenderSky (7560; depth test DISABLED + DepthMask(false), Sky/SkyRenderer.cs:194-195, restored 440-441) + SkyPreScene particles (7565), then TerrainModernRenderer.Draw (7580). Then InteriorEntityPartition outdoor bucket via InteriorRenderer.DrawEntityBucket (7737-7746), the 48m exterior look-in RetailPViewRenderer.DrawPortal (7778-7798, MaxSeedDistance=48f at 7795), LiveDynamic bucket (7813-7823); scene particles (7846-7868: clipRoot==null only); weather post-scene RenderWeather + SkyPostScene particles (7874-7889).
|
||
(5) clipRoot != null (the normal path, indoor AND outdoor): RetailPViewRenderer.DrawInside (7604-7663) with DrawLandscapeSlice = DrawRetailPViewLandscapeSlice (7624-7634), ClearDepthSlice = scissored depth clear for INTERIOR roots only, null for the outdoor node (7644-7652), DrawCellParticles (7653). Outdoor-node LiveDynamic drawn after, unclipped (7716-7724).
|
||
|
||
== RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109) ==
|
||
Order: PortalVisibilityBuilder.Build (flood from root; root seeded at distance 0 mirroring InsCellTodoList(0f), PortalVisibilityBuilder.cs:94; exit portals union into frame.OutsideView, PortalVisibilityBuilder.cs:279; outdoor node seeds OutsideView with a FULL-SCREEN quad, PortalVisibilityBuilder.cs:80-89) → MergeNearbyBuildingFloods for the outdoor node (RetailPViewRenderer.cs:60-61, 115-145: group nearby cells by BuildingId, one ConstructViewBuilding per building with OutdoorBuildingSeedDistance=48f at line 30; ConstructViewBuilding = BuildFromExterior, PortalVisibilityBuilder.cs:548-554 with per-portal NearestPortalVertexDistance > maxSeedDistance cutoff at 426-427) → ClipFrameAssembler.Assemble + UploadClipFrame (63-64) → drawableCells = ALL OrderedVisibleCells (71) → EnvCellRenderer.PrepareRenderBatches (74-80) → InteriorEntityPartition (82) → then the draw stages:
|
||
(a) **DrawLandscapeThroughOutsideView (214-238)**: per OutsideView slice: SetTerrainClip(slice.Planes) + upload + entity clip routing (225-227), then the GameWindow callback **DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551)**: scissor to slice.NdcAabb (9477, BeginDoorwayScissor 9707-9724 = NDC AABB → pixel scissor), sky (clip distances enabled, 9484-9487), SkyPreScene particles (9490-9492), TERRAIN (9494-9496, clipped by the TerrainUbo planes via gl_ClipDistance + the scissor), outdoor entity bucket via WbDrawDispatcher (9503-9512), outdoor-entity particles (9519-9530), WEATHER inside the slice (9532-9541). After ALL slices are drawn: ClearDepthSlice per slice (234-235) — for interior roots a **scissored AABB depth-only clear** (GameWindow.cs:7646-7652); for the outdoor node NO clear at all (7644 rationale comment).
|
||
(b) **DrawExitPortalMasks (325-343)**: the fence hook — iterates reverse OrderedVisibleCells and invokes ctx.DrawExitPortalMasks per slice, but **no production caller sets that callback** (grep over src/: only RetailPViewRenderer.cs and the context type definitions reference it; GameWindow.cs:7604-7663 sets DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles only). The retail depth fence is UNWIRED.
|
||
(c) **DrawEnvCellShells (345-399)**: IndoorDrawPlan.ShellPass = reverse OrderedVisibleCells far→near (IndoorDrawPlan.cs:21-33); per cell per slice: UseShellClipRouting + EnvCellRenderer.Render(Opaque) + Render(Transparent) (388-393). GL_CLIP_DISTANCE is enabled ONLY for outdoor-node roots (clipShells, 104-105, 378-380, 396-398) — **indoor roots draw shells UNCLIPPED** (#114 scope note at 96-103). EnvCellRenderer establishes its own blend/depthmask per pass (Wb/EnvCellRenderer.cs:1085-1094: opaque = blend off + DepthMask(true); transparent = blend on + DepthMask(false); restore at 1277-1278).
|
||
(d) **DrawCellObjectLists (401-426)**: reverse OrderedVisibleCells; per-cell entity buckets via WbDrawDispatcher with **membership-only routing** — UseIndoorMembershipOnlyRouting (420, 439-450) deliberately does NOT plane-clip entities (rationale comment: retail uses viewconeCheck not hard clip); per-cell particles scissored to the slice AABB only (GameWindow.cs:9553-9580; particle.vert has no gl_ClipDistance per the 9701-9706 comment).
|
||
DrawPortal (162-212, the legacy clipRoot==null look-in) mirrors the same stages from BuildFromExterior with the 48m seed.
|
||
|
||
== Culling ==
|
||
Terrain: per-landblock-slot frustum AABB cull only (TerrainModernRenderer.cs:206-223); no per-portal-view CPU cull — the portal restriction is GPU clip planes (<=8) + scissor; sets no cull/depth state of its own (inherits frame state). ClipFrameAssembler caps a slice at 8 planes; regions needing more set a scissor-only fallback (ClipFrameAssembler.cs:136-169 outsideHasScissorFallback → TerrainClipMode.Scissor; per RetailPViewRenderer.cs:368-369 the >8-plane shell fallback is unimplemented = pass-all). Entities: per-landblock frustum AABB cull + per-entity 5m-radius AABB cull (WbDrawDispatcher.cs:208-210, 593-595), clip-slot routing by cell membership (394-488), opaque groups sorted front-to-back / transparent back-to-front (1163-1204, 1439-1445); no per-portal-view sphere test. EnvCell shells: drawableCells filter + per-slice routing.
|
||
|
||
== Sky/weather/particles ==
|
||
Sky always first in whichever pass draws it, no depth test/write (matches retail's ALWAYS+no-write). Weather: drawn inside each landscape slice when renderSky (GameWindow.cs:9532-9541) and post-scene on the null path (7874-7889) — keyed on seen_outside of the VIEWER root, not on is_player_outside. Scene particles: depth test on, depth write off (ParticleRenderer.cs:141-143); on clipRoot!=null frames only entity-attached emitters draw (slice filter 9528-9529 and cell filter 9575-9576 both require AttachedObjectId != 0); the unattached-emitter draw exists only on the clipRoot==null path (7856).
|
||
|
||
## DIVERGENCES
|
||
|
||
### [CRITICAL] missing-portal-depth-fence (confirmed) — Retail's invisible portal depth fence (DrawPortalPolyInternal maxZ2) after the Z-clear is entirely missing; the DrawExitPortalMasks hook exists but is wired to nothing
|
||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), every load-bearing gate checked:
|
||
|
||
1. PView::DrawCells (Ghidra 0x005a4840): confirmed sequence — LScape::draw(this->lscape) → D3DPolyRender::FlushAlphaList(0.0) → vtable+0x2c Clear(4, RGBAColor_Black, 0x3f800000=1.0f), gated on (forceClear || portalsDrawnCount != 0) with portalsDrawnCount reset to 0 at the check → FIRST reverse loop over cell_draw_list × CEnvCell::setup_view(cell, view) × portals where other_cell_id == -1 (0xffffffff) → D3DPolyRender::DrawPortalPolyInternal(portal_poly, false) → THEN the geometry loops (vtable+0x5c cell draw, vtable+0x64 second pass). So the exit-portal fences are drawn AFTER the depth clear and BEFORE cell geometry — exactly the claimed re-fencing role.
|
||
|
||
2. Clear flag semantics verified via RenderDeviceD3D::Clear (Ghidra 0x0059fd30): engine flag bit 1→D3DCLEAR_TARGET, bit 2→D3DCLEAR_STENCIL (cap-gated), bit 4→D3DCLEAR_ZBUFFER. Clear(4, …, 1.0f) is a depth-only clear to z=1.0. Claim's "Clear(D3DCLEAR_ZBUFFER, z=1.0)" correct.
|
||
|
||
3. D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90): mode flag selects maxZ1 (param true) vs maxZ2 (param false). Globals maxZ2=6 @0x00820e14 (pc:1105964), maxZ1=7 @0x00820e18 (pc:1105965). Bit decode from the decompile: SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=(maxZ>>2)&1) → bit2=1 in both 6 and 7 → z-write ON; per-vertex z = (maxZ&1)==0 ? zw/w (TRUE projected depth) : 0x3f7fffef≈0.99999994 (far plane) → maxZ2=6 draws at true depth, maxZ1=7 punches to far; vertex alpha top bit = ~(maxZ<<30)&0x80000000 → bit1=1 in both → alpha 0x00, with SRCALPHA/INVSRCALPHA blend → invisible. SetStageTexture(0,null), SetCullMode(NONE). Mode-false draws increment portalsDrawnCount — the very counter that gates next frame's Z-clear in DrawCells (self-consistent loop). Claim's "invisible (alpha 0), DEPTHTEST_ALWAYS, zwrite ON, at TRUE depth" for maxZ2 and "far punch z≈1.0" for maxZ1: all confirmed.
|
||
|
||
4. Building twin verified: RenderDeviceD3D::DrawMeshInternal (Ghidra 0x0059f360) portals-only path calls BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (…, 2); mode threaded verbatim through BSPNODE::build_draw_portals_only (0x0053c100) → BSPPORTAL::portal_draw_portals_only (0x0053d870, per-portal virtual at render_device vtable+0x4c with (in_portals[i], 1, mode)) → PView::ConstructView(CBldPortal…) (Ghidra 0x005a59a0): `if (param_4 != 2) DrawPortalPolyInternal(poly, param_4 == 1)` and recursion into the other cell only `if (param_4 != 1)`. So mode 1 = punch-without-recurse, mode 2 = recurse-without-punch — the far punch lands before nested interior view construction, as claimed.
|
||
|
||
ACDREAM SIDE — confirmed at the cited lines:
|
||
- RetailPViewRenderer.cs:325-343: DrawExitPortalMasks early-returns at :331-332 when ctx.DrawExitPortalMasks is null; otherwise iterates reverse OrderedVisibleCells × slices — the hook exists and sits at the retail-correct position (called at :95 in DrawInside and :204 in DrawPortal, immediately after DrawLandscapeThroughOutsideView whose tail invokes ClearDepthSlice per OutsideView slice at :234-235, and before DrawEnvCellShells :104 / DrawCellObjectLists :106).
|
||
- Grep over all of src/ finds DrawExitPortalMasks ONLY in RetailPViewRenderer.cs (method, two invocations, three nullable Action property declarations :497/:534/:558). Neither production context initializer sets it: GameWindow.cs:7604-7663 (DrawInside ctx — sets DrawLandscapeSlice, ClearDepthSlice, DrawCellParticles, EmitDiagnostics, NOT DrawExitPortalMasks) and GameWindow.cs:7780-7798 (DrawPortal ctx — sets no draw callbacks at all). The hook is wired to nothing; the method is a per-frame no-op.
|
||
- The only depth partition is the scissored NDC-AABB depth clear: GameWindow.cs:7644-7652 (BeginDoorwayScissor(true, slice.NdcAabb) + glClear(DEPTH)), and it is explicitly null for outdoor-node roots (7644-7645, with the "no clear outdoors" compromise rationale in the comment block 7635-7643).
|
||
|
||
DIVERGENCE IS REAL, NOT EQUIVALENT-ELSEWHERE: after acdream's per-slice clear, aperture depth is 1.0 and nothing re-writes the doorway's true depth before shells/objects draw — any later geometry at any depth wins those pixels. Retail re-fences with the exact portal POLYGON at true depth (and the AABB-vs-polygon shape mismatch is an additional acdream approximation the port would eliminate). Outdoor roots lack the mode-1 far punch entirely (ClearDepthSlice=null is a compromise, not an equivalent). Strengthening note: in retail the depth fence works IN CONCERT with screen-space portal clipping (Render::set_view pc:343750 + polyClipFinish planeMask=0xffffffff pc:427922); acdream currently draws indoor-root shells UNCLIPPED (clipShells only for outdoor roots, RetailPViewRenderer.cs:104-105 + :374-380 #114 scope) — so indoor roots have NEITHER protection, making the missing fence directly load-bearing for #109's oscillating far-door aperture and the §4 "indoor geometry paints over the doorway view" class.
|
||
|
||
Minor port-shape details surfaced (do not change the verdict): (a) retail's Z-clear is GATED on forceClear || prev-frame portalsDrawnCount≠0, not unconditional; (b) DrawPortalPolyInternal skips degenerate portal polys whose vertices all sit at ±12.0 cell-boundary extents and screen-clips the poly (needs ≥3 clipped verts) before drawing; (c) the fence loop in DrawCells covers EVERY visible cell's exit portals (other_cell_id==-1), not just the root cell's. One honest open sequencing question for the port plan: the building far punch is drawn during the building-mesh draw (LScape::draw path) which precedes DrawCells' conditional Z-clear — on frames where the clear fires (interior fences drawn last frame), the punch region is wiped to 1.0 anyway (same value), but exterior WALL depth is also wiped, implying retail leans on screen-space portal clipping as the primary aperture discipline with the depth fence as the in-aperture layering mechanism; the port should treat clipping+fence as a pair, not the fence alone.
|
||
- blastRadius: #109 (far exit door oscillates door-texture vs background): after our scissored depth clear, depth in the aperture region is 1.0 and NOTHING re-establishes the doorway's depth, so any interior/shell geometry drawn later — at ANY depth — wins the aperture; whether the outdoor view or the door-region geometry shows depends on per-frame flood/slice makeup → oscillation. Also a contributor to #108 and to the general 'indoor geometry paints over the doorway view' class the digest tracks under §4.
|
||
- retailEvidence: PView::DrawCells (Ghidra 0x005a4840, pc:432703): after LScape::draw + FlushAlphaList + Clear(D3DCLEAR_ZBUFFER, z=1.0) (0x5a48a9), it loops every cell (reverse) × every view (CEnvCell::setup_view) × every portal with other_cell_id==0xffffffff and calls D3DPolyRender::DrawPortalPolyInternal(portal_poly, 0) (0x5a49b7). DrawPortalPolyInternal (Ghidra 0x0059bc90) with maxZ2=6 (pc:1105964) draws the aperture polygon invisible (alpha 0), DEPTHTEST_ALWAYS, zwrite ON, at TRUE depth — re-fencing the doorway so interior geometry farther than the door fails depth there. The outdoor-side twin is the mode-1 far punch (maxZ1=7, z≈1.0) issued per building portal before the nested interior DrawCells (ConstructView(CBldPortal) Ghidra 0x005a59a0: `if (param_4 != 2) DrawPortalPolyInternal(poly, param_4==1)`; DrawMeshInternal 0x0059f360 calls build_draw_portals_only with modes 1 then 2).
|
||
- acdreamEvidence: RetailPViewRenderer.cs:325-343 (DrawExitPortalMasks) iterates and invokes ctx.DrawExitPortalMasks — but GameWindow.cs:7604-7663 (DrawInside ctx) and 7780-7798 (DrawPortal ctx) never set the callback; grep over src/ finds no other assignment. The only depth partition is the scissored AABB depth clear (GameWindow.cs:7644-7652), null for outdoor-node roots.
|
||
- portShape: Implement the two invisible depth-only portal-poly draws as a small dedicated pass: (1) indoor roots — after the per-slice landscape+depth-clear stage, draw each outside-leading portal polygon (the exact portal poly from the cell's CCellPortal, world-space) with depth-func ALWAYS, depth-write ON, color mask OFF, at true depth (retail maxZ2=6); (2) outdoor roots — before each per-building flood's interior shells draw, draw that building portal's polygon with depth forced to the far plane (gl_FragDepth=1.0 or glDepthRange trick; retail maxZ1=7), replacing the 'no clear outdoors' compromise. Wire both through the existing DrawExitPortalMasks hook (it already iterates reverse cells × slices). This removes the depth-clear-shape dependency entirely.
|
||
|
||
### [CRITICAL] approximate-portal-clip-for-landscape (adjusted) — Terrain/sky through portals is clipped by ≤8 GL planes + an NDC-AABB scissor instead of retail's exact software polygon clip per portal view
|
||
- correctedClaim: Retail does NOT software-clip terrain/sky polygons per portal view — that part of the claim is wrong. Retail accumulates EXACT clipped portal polygons into outside_view (ClipPortals 0x005a5520 → copy_view 0x0054dfc0, ≤31 verts/edges per view poly with per-edge world planes), then culls landscape per accumulated view at LANDBLOCK→LANDCELL (~24 m) granularity only (draw_check_blocks 0x00505f80, landcell_check 0x005050a0, get_clip_height 0x0054cff0 testing ALL edge planes), and draws in-view land cells WHOLE (DrawLandCell 0x0059f120 → landPolysDraw 0x006b7040 → DrawPrimitiveUP, no clip, no scissor; polyClipFinish 0x006b6d00 is called only by DrawPortalPolyInternal and GetClip). Pixel exactness in retail comes from compositing in DrawCells 0x005a4840: landscape first → depth clear → software-clipped exit-portal depth masks (DEPTHTEST_ALWAYS + z-write) → shells/objects overdraw the overspill. acdream instead confines the landscape pass at draw time with ≤8 gl_ClipDistance planes + per-slice NDC-AABB scissor (ClipPlaneSet.cs:54,132; ClipFrameAssembler.cs:134-169; GameWindow.cs:9477,9484-9496,9707-9724) — which is pixel-EXACT and equal-or-tighter than retail whenever the view poly has ≤8 edges (the common doorway case). The real, narrower divergences: (1) view polys with >8 edges (possible with nested portal chains; retail handles up to 31) degrade to AABB-only landscape over-coverage and pass-all shells (RetailPViewRenderer.cs:367-369); (2) particle passes are confined by AABB scissor only (no gl_ClipDistance in particle.vert; GameWindow.cs:9490-9492,9518-9530,9568-9578), where retail confines particles by depth against already-drawn shells; (3) acdream's exactness depends on the clip representation per slice, retail's on the z-clear/exit-mask/overdraw discipline — so any acdream pass that skips both planes and the mask discipline inherits the AABB slop. Severity: medium (not critical); the ≤8-edge equivalence and the zero-scissor-fallback pin at the Issue-113 site make it unlikely to be the primary cause of #108.
|
||
- verifier notes: RE-CHECKED RETAIL (all via Ghidra decompile, 127.0.0.1:8081 — not BN pseudo-C):
|
||
|
||
CONFIRMED parts of the retail claim:
|
||
1. PView::ClipPortals @ 0x005a5520: GetClip software-clips each portal poly against the current view; when other_cell_id==0xffffffff (outside sentinel) and draw_landscape!=0 && cliplandscape!=0, Render::copy_view(&this->outside_view, clip_view, n) accumulates the EXACT clipped polygon into outside_view (LAB_005a5711 path). Matches pc:433662-433682.
|
||
2. LScape::draw_check_blocks @ 0x00505f80: loops Render::PortalList->view_count, calling Render::set_view(&PortalList->view, i) per accumulated view poly, then Render::get_clip_height + Render::block_check per landblock. PView::DrawCells @ 0x005a4840 sets Render::PortalList = &this->outside_view before LScape::draw — so landscape culling is per accumulated outside_view poly. get_clip_height @ 0x0054cff0 iterates ALL portal_npnts per-edge world planes of the current view poly (planes built in copy_view @ 0x0054dfc0, last loop: cross of unprojected edge dirs through viewpoint) — no 8-plane budget.
|
||
3. "No scissor" — true, no scissor anywhere in the retail landscape path.
|
||
|
||
REFUTED parts of the retail claim:
|
||
4. "Every terrain poly is software-clipped through ACRender::landPolysDraw → polyClipFinish ... to the pixel" is FALSE. landPolysDraw @ 0x006b7040 only backface-culls (Plane::which_side2) and dispatches to landPolyDraw @ 0x006b6320 / 0x006b6760, both of which build D3D vertices and call RenderDeviceD3D::DrawPrimitiveUP directly — no view clip, no scissor, no D3D user clip planes (no SetClipPlane symbol exists). polyClipFinish @ 0x006b6d00 xrefs are ONLY D3DPolyRender::DrawPortalPolyInternal (call at 0x0059bdb0) and PView::GetClip (0x005a43b2, 0x005a4414) — portal polys only, never terrain. landPolysDraw's only caller is RenderDeviceD3D::DrawLandCell @ 0x0059f120.
|
||
5. "No plane-count cap" is FALSE: Render::copy_view @ 0x0054dfc0 clamps a stored view poly to 0x1f = 31 vertices/edges (and dedups verts within ~1px). 31 >> 8, but a cap exists.
|
||
6. Retail's terrain confinement granularity is the LANDCELL (~24 m): landcell_check @ 0x005050a0 refines block cull to per-cell in_view flags; in-view cells are drawn WHOLE. Pixel exactness comes from COMPOSITING, not clipping — DrawCells @ 0x005a4840 sequence: LScape::draw (terrain first, over-covering at cell granularity) → depth-buffer clear (Clear(4, black, 1.0f), gated on portalsDrawnCount/forceClear) → per cell per view, DrawPortalPolyInternal on every other_cell_id==-1 portal (software-clipped via polyClipFinish against the current view, drawn DEPTHTEST_ALWAYS + conditional z-write = the exit-portal depth mask) → cell shells → objects. Shells drawn after overwrite all terrain overspill; the masks preserve aperture pixels.
|
||
|
||
RE-CHECKED ACDREAM (all cited lines verified):
|
||
- ClipPlaneSet.cs:54 (MaxPlanes=8), :119-120 (multi-poly → union-AABB scissor), :132-133 (>8 edges → own-AABB scissor). ClipFrameAssembler.cs:134-169: outside_view slices built per polygon (ViewOf wraps ONE poly per slice, so unions become multiple slices — structurally matching retail's per-view loop, line 96-121 same for cells); TerrainClipMode.Scissor when any outside slice lacks planes (:167-169). RetailPViewRenderer.cs:367-369: shell-side slot-0 pass-all, >8-plane fallback unimplemented (comment also pins 0 such slices at the meeting hall via Issue113MeetingHallFloodTests). GameWindow.cs:9477 BeginDoorwayScissor(true, slice.NdcAabb) over the whole landscape slice; 9484-9496 EnableClipDistances around sky+terrain; 9490-9492/9518-9530/9568-9578 particle passes scissor-only (particle.vert has no gl_ClipDistance, per 9701-9706 comment); 9707-9724 NDC→pixel scissor impl. All as claimed.
|
||
|
||
JUDGMENT: the acdream description is accurate, but the divergence is mischaracterized in a way that flips the port recommendation. For the common doorway case (view poly ≤8 edges after collinear merge), acdream's gl_ClipDistance planes ARE the exact polygon — pixel-exact, equal-or-TIGHTER than retail's pre-composite terrain coverage (whole 24 m cells). The claim's "retail clips terrain exactly, acdream approximates" is inverted: retail approximates harder at draw time and relies on draw-order compositing (z-clear + exit-mask + shell overdraw) for exactness — a discipline acdream already partially ports (DrawExitPortalMasks at RetailPViewRenderer.cs:330-343, doorway depth-only z-clear per GameWindow.cs:9701-9706). The real residual gaps are: (a) >8-edge view polys degrade to AABB-only on the landscape pass and pass-all on shells; (b) particle passes are AABB-scissor-only; (c) retail's 31-edge cap vs our 8. "Critical / #108 primary" does not survive: the common case is behaviorally equivalent and the in-tree test pin reports zero scissor-fallback slices at the #113 site; no evidence ties >8-edge slices to the #108 repro. The proposed stencil port is not retail's mechanism (though it remains a defensible GPU-native option for the >8-edge residue).
|
||
- blastRadius: #108 primary; #114 region quality; doorway 'grey'/'sweep' family in the render digest §4.
|
||
- retailEvidence: Indoor outdoor-view: ClipPortals accumulates the EXACT clipped portal polygons into outside_view (0x005a5520 → Render::copy_view at 0x5a5711 path, gated by draw_landscape/cliplandscape pc:433662-433682); LScape::draw_check_blocks then culls blocks per accumulated view via Render::set_view(&PortalList->view,i) (Ghidra 0x00505f80), and every terrain poly is software-clipped through ACRender::landPolysDraw → polyClipFinish (DrawLandCell thunk pc:427860; same clipper the portal polys use at 0x59bc90). There is no scissor and no plane-count cap — the clip region is the exact polygon union, to the pixel.
|
||
- acdreamEvidence: ClipFrameAssembler.cs:136-169: a slice gets at most 8 half-space planes (ClipFrame.MaxPlanes); regions needing more fall back to scissor-only (TerrainClipMode.Scissor); RetailPViewRenderer.cs:368-369 records the shell-side >8-plane fallback as unimplemented (pass-all). DrawRetailPViewLandscapeSlice draws sky/terrain/scenery under BeginDoorwayScissor(slice.NdcAabb) (GameWindow.cs:9477, 9707-9724) + gl_ClipDistance planes (9484-9496). The AABB is axis-aligned and the plane set is convex — a rotated/concave doorway union is over-covered by construction.
|
||
- portShape: Faithful port = make the landscape slice's coverage exact: either (a) per-slice stencil mask — rasterize the exact OutsideView polygons into the stencil buffer once per frame and stencil-test the sky/terrain/scenery/weather slice draws (GPU-native equivalent of retail's software clip; removes the 8-plane cap and the AABB slop), or (b) triangulate the clip region and draw the landscape through a clipped viewport per polygon. Option (a) is the one-gate-shaped fix and also gives the cell shells their pixel-exact crop (#114).
|
||
|
||
### [HIGH] depth-clear-shape-and-order (adjusted) — Depth partition: retail clears the FULL depth buffer once (gated on portalsDrawnCount) between the outdoor stage and the interior stage; acdream clears per-slice scissored AABBs after all slices, and skips the clear entirely for outdoor-node roots
|
||
- correctedClaim: Depth partition: retail's DrawCells (0x5a4840) issues ONE full-buffer Z-only clear — RenderDeviceD3D::Clear(4→D3DCLEAR_ZBUFFER, Count=0/pRects=NULL, full-screen viewport) @ 0x59fd30 — between the landscape stage and the interior stage, gated read-and-clear on `forceClear || portalsDrawnCount` (the count increments only on fence-mode portal-poly draws, DrawPortalPolyInternal @ 0x59bc90 arg2=false), then re-fences each outside portal at its TRUE depth (maxZ2=6, DEPTHTEST_ALWAYS+write, color-invisible). Outdoors retail never reaches a top-level DrawCells (xrefs: only DrawInside/DrawPortal); instead each building portal gets a mode-1 far punch (maxZ1=7 → z≈0x3f7fffef) before the mode-2 DrawCells re-entry draws the interior with outside_view reset to 0. acdream (src/AcDream.App/Rendering/GameWindow.cs:7644-7652, RetailPViewRenderer.cs:234-235) instead clears per-slice scissored NDC-AABBs after all landscape slices for interior roots, has NO portal-depth fence, and skips the clear entirely for outdoor-node roots — flooded interiors fight terrain on raw depth. Blast radius correction: for interior roots the AABB clear DOES wipe terrain depth inside the AABB (the landscape slice is scissored to the same AABB, GameWindow.cs:9477) — the #108 artifact mechanism indoors is surviving terrain COLOR backed by far depth with no fence (protected region = AABB ≠ aperture), plus outdoors the no-clear/no-punch raw depth fight (terrain nearer than interior when the eye is below ground); #109 contributor framing (AABB ≠ aperture ≠ door-entity draw) stands. Both issue texts name the depth-clear as suspect; attribution plausible but uncaptured.
|
||
- verifier notes: RETAIL re-derived from Ghidra (not BN). (1) PView::DrawCells @ 0x5a4840: inside the `outside_view.view_count != 0` block, after LScape::draw + D3DPolyRender::FlushAlphaList, the decompile shows `if (forceClear || (portalsDrawnCount != 0)) { portalsDrawnCount = 0; render_device->vtbl[+0x2c](4, &RGBAColor_Black, 1.0f); }` — the claimed gate, at the claimed 0x5a4893-0x5a48a9 range (BN pc:432727-432728 shows check @ 0x5a489c, reset @ 0x5a489e), positioned between the landscape stage and the interior-cell stage. Note the count is read-and-clear at the check, and short-circuit means forceClear=true skips the reset. (2) The vtable slot resolves to RenderDeviceD3D::Clear @ 0x59fd30 (vtable entry 0x7e552c): engine flag 4 remaps to D3D bit 2 = D3DCLEAR_ZBUFFER; IDirect3DDevice9::Clear (slot 0xac) is issued with Count=0, pRects=NULL, bracketed by full-screen viewport set/restore — a genuine full-buffer Z-only clear, shape-unconditional. (3) Gate semantics: D3DPolyRender::DrawPortalPolyInternal @ 0x59bc90 increments portalsDrawnCount iff arg2==false (Ghidra: `if (!param_2) portalsDrawnCount++`), i.e. fence-mode draws. maxZ1=7 @ 0x820e18 / maxZ2=6 @ 0x820e14: bit 2 → depth-write on for both, DEPTHTEST_ALWAYS for both; bit 0 → vertex z forced to 0x3f7fffef (≈1.0 far) for maxZ1 (arg2=true, the per-aperture FAR PUNCH) vs the poly's true projected z for maxZ2 (arg2=false, the FENCE); alpha bit forced 0 → color-invisible. (4) Outdoor chain confirmed: RenderDeviceD3D::DrawMeshInternal @ 0x59f360 (building path; claimed 0x59f3cc is the build_draw_portals_only call region inside it) → BSPTREE::build_draw_portals_only @ 0x539860 modes 1 then 2 → BSPPORTAL::portal_draw_portals_only @ 0x53d870 → RenderDeviceD3D::DrawPortal @ 0x59f0e0 → PView::DrawPortal @ 0x5a5ab0: mode 1 = ConstructView success → DrawPortalPolyInternal(poly, true) = far punch only (no DrawCells); mode 2 = cell-side ConstructView @ 0x5a57b0 — whose FIRST statement resets outside_view.view_count=0 — then DrawCells re-entry. DrawCells xrefs show ONLY DrawInside (0x5a595b) and DrawPortal (0x5a5b53) as callers: there is NO outdoor-root DrawCells, so outdoors the gated full clear is structurally skipped and the mode-1 far punch is retail's only outdoor depth-isolation mechanism — this STRENGTHENS the claimed retail dichotomy (indoors: full clear + true-depth fence; outdoors: per-aperture far punch). ACDREAM verified at the cited lines (path nit: the file is src/AcDream.App/Rendering/GameWindow.cs, not src/AcDream.App/GameWindow.cs): GameWindow.cs:7644-7652 `ClearDepthSlice = clipRoot.IsOutdoorNode ? null : slice => { BeginDoorwayScissor(true, slice.NdcAabb); _gl.Clear(DepthBufferBit); ... }` with the full-screen-slice hazard comment at 7635-7643; BeginDoorwayScissor @ 9707-9724 converts the NDC AABB to a pixel scissor rect; RetailPViewRenderer.cs:234-235 (DrawLandscapeThroughOutsideView, 214-238) invokes the clears AFTER all slices drew; DrawInside stage order 44-108 (landscape+clears → exit-portal masks → shells → object lists). Grep confirms NO DepthFunc-Always fence or far-punch anywhere in RetailPViewRenderer.cs. The divergence is REAL and not behaviorally equivalent: indoors acdream clears an AABB (not full buffer) and never re-fences portal depth; outdoors acdream has neither clear nor punch, so flooded interiors fight terrain on raw depth — retail wins inside the aperture by construction. ONE BLAST-RADIUS SENTENCE IS WRONG AS WORDED: for interior roots, terrain DEPTH inside the AABB does NOT survive — the landscape slice draw is scissored to the SAME AABB the clear later wipes (GameWindow.cs:9477), so within slice AABBs depth is reset; what survives is terrain COLOR (now backed by far depth, with no fence to restore aperture depth for later passes). The outdoor half of the #108 reasoning (no clear, no punch → interiors lose raw depth fights when terrain is nearer, e.g. eye below ground) is correct as stated, and during the cellar ascent the root flips indoor/outdoor so both regimes plausibly contribute. #108/#109 attribution is consistent with the issue texts, which independently name the doorway depth-clear as a suspect (docs/ISSUES.md:3677-3690, 3694-3706; #108 explicitly 'needs its own capture' — attribution plausible, unproven). Severity HIGH stands; the port-shape coupling to the fence + exact-clip divergences is sound (the fence is what makes a full clear safe; the punch is what makes no-clear safe).
|
||
- blastRadius: #108 (with the eye below outdoor terrain, terrain depth deposited outside the exact aperture but inside the AABB survives wherever shells don't repaint, and outdoors — no clear, no punch — flooded interiors must win raw depth fights against terrain, which they lose when terrain is nearer than the interior, e.g. eye below ground); #109 contributor (the clear region is an AABB, not the aperture, so the protected region ≠ the drawn region).
|
||
- retailEvidence: PView::DrawCells 0x5a4893-0x5a48a9: `if (forceClear || portalsDrawnCount) render_device->Clear(4 /*Z only*/, 0x820fc0, 1.0f)` — one full-buffer depth clear, unconditional on shape, AFTER the complete landscape stage; correctness of the aperture is then delegated to the fence (see missing-portal-depth-fence) and to the software clip having confined outdoor COLOR. Outdoors the per-aperture far punch (mode 1, maxZ1) plays the clear's role per building portal (DrawMeshInternal 0x59f3cc).
|
||
- acdreamEvidence: GameWindow.cs:7644-7652: ClearDepthSlice = scissored Clear(DepthBufferBit) per slice for interior roots, null for the outdoor node (comment explains the full-screen-slice hazard); RetailPViewRenderer.cs:234-235 invokes it after ALL slices drew. The outdoor node relies on raw depth between terrain and flooded interiors.
|
||
- portShape: Once the fence + far-punch land (divergence 1) and the clip is exact (divergence 2), replace the scissored per-slice clear with retail's single full depth clear gated on 'any outside slice drew' for interior roots, and delete the outdoor-node no-clear special case in favor of the per-portal far punch. The three mechanisms are a set — porting them together is what makes each one safe.
|
||
|
||
### [HIGH] portal-poly-conditional-draw (UNVERIFIED (verifier hit token limit)) — Baked portal-filling polys (door/window quads, the e223325 finding) draw unconditionally as ordinary mesh geometry; retail routes them exclusively through the DrawPortal mode machinery (punch/fence/nothing), never as part of the shell pass
|
||
- blastRadius: #113 phantom staircase (portal polys to interior stair cells drawn as if solid geometry — confirmed same mechanism by e223325) and the e46d3d9 door regression (filtering them out also removed legitimately-visible fillings); #109 (the door-texture half of the oscillation is the unconditional filling quad fighting the outdoor slice — for outdoor roots the quad sits geometrically coincident with the clip planes derived from the same polygon, so the clipShells gl_ClipDistance crops it with boundary-epsilon flicker).
|
||
- retailEvidence: Portal polys live in BSPPORTAL::in_portals and are drawn ONLY by device->DrawPortal from the portals-only walk (BSPPORTAL::portal_draw_portals_only pc:326881, call sites 0x53d9a3/0x53d953 — the only emission path); the shell pass draws the constructed mesh via D3DPolyRender::DrawMesh (0x59f3f4 → 0x59d4a4) with no portal handling; DrawPortal's three outcomes for the poly are far-punch (mode 1, invisible), nothing (mode 2), or true-depth invisible fence on flood-fail (mode 3, no caller found in the built-mesh path) — all DEPTH-ONLY. (Where the TEXTURED filling draws is the open question below; what is certain is it is not unconditional shell geometry.)
|
||
- acdreamEvidence: Post-revert 124c6cb our GfxObj building meshes contain every dictionary poly including node.Portals fillings, drawn by the normal Wb mesh path; e223325 proved all 13 Holtburg building models' non-node.Polygons polys are portal polys. RetailPViewRenderer.cs:104-105 clips shells (incl. those quads) by slice planes for outdoor roots; indoor roots draw them unclipped (378-380).
|
||
- portShape: Separate the portal polys from the static mesh at build time (the e223325 classification makes this mechanical: node.Portals refs) into a per-portal side list, then drive them from the DrawPortal-equivalent: flood succeeded → do not draw the filling (draw the depth fence/punch instead); flood failed/not attempted → draw the filling textured (pending resolution of the open question on retail's exact textured-fill site). This is the '#113 and doors are the same mechanism with opposite signs' port.
|
||
|
||
### [HIGH] building-flood-seeding-48m-cutoff (adjusted) — Interior floods seed from a 48m per-building distance cutoff over Chebyshev≤1 landblocks; retail floods from the building's BSP portal walk during that building's draw with only plane-side + view-clip + cell-loaded gates (no distance cutoff)
|
||
- correctedClaim: Interior floods in acdream seed only from exit portals within a hard 48m camera-to-portal-vertex cutoff (RetailPViewRenderer.cs:30,141-142; PortalVisibilityBuilder.cs:426-428) over a Chebyshev<=1 landblock ring around the player (GameWindow.cs:7463-7477; legacy look-in 7759-7795) — while building exteriors draw out to the full near-tier streaming radius. Retail floods every drawn building: LScape::draw (0x506330, frustum in_view gate) -> DrawBlock (0x5a17c0, per-cell view gate) -> DrawSortCell (0x59f140) -> DrawBuilding (0x59f2a0) installs the building's CBldPortal list unconditionally, and PView::ConstructView (0x5a59a0) gates each portal purely on viewer plane-side vs F_EPSILON matched to portal_side, GetClip non-emptiness, CEnvCell::GetVisible loaded-cell lookup (0x52dc10), and copy_view success — no flood-specific distance constant; retail's only distance bounds (LScape block window, degrade-slot null check) remove the entire building from view, so flood eligibility always equals building visibility and a visible aperture can never pop. User-visible consequences: interiors missing (static filling quads instead of through-the-door views) at >48m through visible doors/windows, and a threshold pop for an outdoor viewer whose eye jitters across the 48m seed boundary. NOT a cause of #109 as filed — #109 is an indoor-root across-the-room draw-order/depth oscillation (~10m) where the 48m path never executes (RetailPViewRenderer.cs:60). Port shape stands: replace the 48m+ring predicate with retail's gates (seed every frustum-passed candidate building; per-portal plane-side + clip-non-empty + cell-loaded), noting the candidate gather must widen from the 1-LB ring in the same change since the ring becomes binding once 48m is lifted.
|
||
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), chain now VTABLE-VERIFIED, and it checks out:
|
||
(1) LScape::draw (0x506330): iterates the full block_draw_list (mid_width^2 blocks) and draws every block with in_view != OUTSIDE (set by draw_check_blocks 0x505f80); the per-block dispatch is render_device vtable+0x50 = 0x7e5550 = DrawBlock (xref-confirmed).
|
||
(2) RenderDeviceD3D::DrawBlock (0x5a17c0): per-cell loop gated only on the cell's own view check (cell vtable+0x68) or alwaysDrawObjects, then calls this vtable+0x58 = 0x7e5558 = DrawSortCell (xref-confirmed). No distance term.
|
||
(3) RenderDeviceD3D::DrawSortCell (0x59f140): if (cell->building) call vtable+0x68 = 0x7e5568 = DrawBuilding (xref-confirmed).
|
||
(4) RenderDeviceD3D::DrawBuilding (0x59f2a0): FIRST line installs outdoor_pview->outdoor_portal_list = building->portals (unconditional), then CPhysicsPart::UpdateViewerDistance (LOD/degrade selection) and a degrade-slot gfxobj null check before drawing the parts. The flood is triggered by portal polys encountered during the part draw: RenderDeviceD3D::DrawPortal (0x59f0e0) -> PView::DrawPortal (0x5a5ab0) -> PView::ConstructView(CBldPortal) (0x5a59a0).
|
||
(5) PView::ConstructView (0x5a59a0) gates, verified in the Ghidra decompile: (a) viewer-eye dot portal plane vs ::F_EPSILON producing Sidedness, matched against portal_side (POSITIVE required for side 0, NEGATIVE for side 1); (b) GetClip output non-empty; (c) CEnvCell::GetVisible(other_cell_id) non-null — decompiled 0x52dc10: a pure hash lookup in visible_cell_table, i.e. a loaded-cell check, NOT a distance check; (d) copy_view success. NO distance term anywhere in the flood path. Citation fix: the claim attributed 0x5a59a0 to CEnvCell::GetVisible — 0x5a59a0 is ConstructView itself; GetVisible is 0x52dc10/0x52ad40.
|
||
|
||
ACDREAM SIDE — all cited lines verified against the working tree:
|
||
- RetailPViewRenderer.cs:30 `OutdoorBuildingSeedDistance = 48f`; :60 MergeNearbyBuildingFloods runs only for IsOutdoorNode roots; :137-144 per-building ConstructViewBuilding(group, ..., 48f); PortalVisibilityBuilder.cs:548-554 ConstructViewBuilding is a passthrough to BuildFromExterior(maxSeedDistance); :426-428 `seedDistance > maxSeedDistance => continue` skips the exit-portal seed (seedDistance = camera-to-nearest-portal-vertex, :350/:426).
|
||
- Candidate gather: GameWindow.cs:7463-7477 (live R-A2 path, Chebyshev<=1 landblocks around the PLAYER landblock -> _outdoorNodeBuildingCells -> ctx.NearbyBuildingCells at GameWindow.cs:7610) and GameWindow.cs:7759-7776 + 7795 (legacy look-in path, same ring + MaxSeedDistance=48f, only runs when clipRoot is null). Grep over src confirms 48f is passed at BOTH production call sites and the PositiveInfinity defaults are never used in production.
|
||
- Building EXTERIORS draw via the normal entity path out to the near-tier streaming radius (N1=4 LBs), far beyond 48m — so acdream renders a building whose doorway aperture is visible at e.g. 100m but never floods its interior. Post-124cb6cc (DrawingBSP filter revert) the baked portal-filling quads draw unconditionally, so such doors show the static filling quad instead of retail's conditional through-the-aperture interior — partially masked, still not retail.
|
||
|
||
ADJUSTMENTS (why not 'confirmed'):
|
||
(A) The #109 blast-radius attribution is WRONG. #109 as filed (docs/ISSUES.md:3694-3706) is an INDOOR-root scenario: standing INSIDE a Holtburg house looking at the other exit door across the room (~10m), oscillating between door texture and background, explicitly suspected as OutsideView-slice / doorway depth-clear / door-entity draw-order interaction, and explicitly noted as distinct from the (fixed) flood strobe. On an indoor root MergeNearbyBuildingFloods never executes (RetailPViewRenderer.cs:60 gate) — the 48m predicate is not in the code path at all. The 48m-boundary jitter-pop mechanism is real but applies to an OUTDOOR viewer near 48m from a doorway; no filed issue currently matches it.
|
||
(B) Retail wording needs one nuance: retail's flood eligibility IS bounded by distance indirectly — (i) the LScape block window (block_draw_list spans the landscape draw radius) and (ii) DrawBuilding's degrade-slot null check after UpdateViewerDistance can suppress the whole building draw, not just mesh choice. But both bounds coincide exactly with "the building is drawn at all": a building too far to draw shows no aperture either, so no pop is ever user-visible. The correct invariant: flood eligibility == building visibility; retail has NO flood-specific distance constant.
|
||
(C) The Chebyshev<=1 ring is non-operative today: ring coverage is >=192m in every direction from the player while the 48m gate binds first (the GameWindow.cs:7755-7758 comment says exactly this). It becomes the binding constraint only once the 48m is lifted — the port shape must widen the gather to frustum-passed candidates in the same change (the claimed port shape already says this; correct).
|
||
|
||
The core divergence is REAL, not behaviorally-equivalent, and not handled elsewhere (grep confirms no other interior-flood path outdoors). Severity high stands on the artifact class (interiors absent through any visible aperture beyond 48m + threshold pop at the 48m boundary, both breaking the one-drawing-discipline invariant), but the headline user-bug tie to #109 must be dropped.
|
||
- blastRadius: #109's 'far' dimension: an exit door near the 48m seed boundary (or outside the 1-LB ring) flickers between flooded (interior/outdoor view through the aperture) and not flooded (filling quad / background) as the eye jitters — retail's gate is purely geometric visibility so a visible distant door never pops. Also explains interiors visibly missing through distant buildings' windows/doors at >48m.
|
||
- retailEvidence: DrawBuilding runs for every cell whose block passed block_check (LScape::draw 0x506330 → DrawBlock 0x5a17c0 → DrawSortCell 0x59f140 → DrawBuilding 0x59f2a0) — every in-view building gets the portals-only walk regardless of distance; ConstructView(CBldPortal) gates only on viewer plane-side (epsilon 2e-4), GetClip non-emptiness, and CEnvCell::GetVisible (Ghidra 0x005a59a0). The part-level LOD (UpdateViewerDistance/deg_level, 0x59f2bc-0x59f2d3) affects mesh choice, not flood eligibility.
|
||
- acdreamEvidence: RetailPViewRenderer.cs:30 (OutdoorBuildingSeedDistance=48f) + 137-144 (per-building ConstructViewBuilding) + GameWindow.cs:7472 (Chebyshev≤1 LB candidate gather) + 7795 (MaxSeedDistance=48f on the legacy look-in); PortalVisibilityBuilder.cs:426-427 (NearestPortalVertexDistance > maxSeedDistance ⇒ skip seed).
|
||
- portShape: Replace the distance cutoff with retail's gates: seed every candidate building whose landblock/cell passed the frustum cull, gate per portal on plane-side + clipped-view non-emptiness + cell-loadedness. The R-A2 per-building grouping itself is retail-faithful (one ConstructView per CBldPortal) and KEEP-listed — only the eligibility predicate diverges. Perf guard: the view-clip gate rejects far/off-screen portals cheaply, which is exactly retail's mechanism.
|
||
|
||
### [MEDIUM] entity-cull-no-portal-viewcone (confirmed) — Entities/objects are culled by frustum + cell membership only; retail additionally sphere-tests every drawn part against the CURRENT portal view's planes (viewconeCheck)
|
||
- correctedClaim: Confirmed as claimed, with three precision upgrades for the port plan: (1) the DrawObjCellForDummies call site is 0x005a4b0d (pc:432878), with Render::PortalList assigned in the same statement region; (2) retail's gate is a per-SLICE loop — DrawMesh (0x005a0860) iterates PortalList->view_count, set_view (0x0054d0e0) per slice, viewconeCheck (0x0054c250) per slice, and skips the part only when OUTSIDE in ALL slices — plus a once-per-frame part dedup (DrawMeshInternal 0x0059f360, GetDrawnThisFrame/SetDrawnThisFrame, player parts exempt) that a faithful port must reproduce; (3) acdream's indoor per-cell entity path is weaker than claimed: it bypasses even the frustum/AABB cull (RetailPViewRenderer.cs:465+474 pass the entry's own landblock as neverCullLandblockId into WbDrawDispatcher.cs:593-595/:662), so entities there are gated by cell membership alone.
|
||
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (127.0.0.1:8081), per the BN-invented-branch warning:
|
||
|
||
1. Render::viewconeCheck @ 0x0054c250 (Ghidra decompile): scales the part's drawing sphere by object_scale, transforms center to viewer space (Position::localtoglobal + Frame::globaltolocal vs viewer_pos), tests the signed distance against viewer_world_space.CY (returns OUTSIDE if dist < -radius), then loops `portal_npnts` planes at `Render::portal_vertex` (24-byte view_vertex stride; plane read as N.x/N.y/N.z/d), returning OUTSIDE on the first fully-behind plane, else PARTIALLY_INSIDE or ENTIRELY_INSIDE. Exactly as claimed — it is a sphere-vs-plane-set BoundingType test, no geometry modification.
|
||
|
||
2. PView::DrawCells @ 0x005a4840 (Ghidra decompile) stage 3 (the loop that runs when the shell/portal stages are done): for each cell in cell_draw_list, `Render::PortalList = (cell->portal_view).data[cell->num_view - 1]` immediately before the render-device vtable call; the callee is RenderDeviceD3D::DrawObjCellForDummies — confirmed by pc:432878 (call at 0x005a4b0d; the claim's "0x5a4b07" is the same statement, off by 6 bytes) and the vtable slot assignment at pc:1037072.
|
||
|
||
3. Full chain to the per-part cull, every link decompiled: DrawObjCellForDummies @ 0x005a0760 (UpdateObjCell + CShadowPart::insertion_sort + vtbl) → DrawObjCell @ 0x005a1a40 → DrawPartCell @ 0x005a07a0 (iterates the cell's shadow_part_list) → CShadowPart::draw @ 0x006b50d0 → CPhysicsPart::Draw @ 0x0050d7a0 → vtbl+0x70 = RenderDeviceD3D::DrawMesh @ 0x005a0860.
|
||
|
||
4. DrawMesh @ 0x005a0860 (Ghidra decompile) is the gate: when Render::PortalList != null it loops i over PortalList->view_count, calls Render::set_view(&PortalList->view, i) — set_view @ 0x0054d0e0 installs that slice's portal_npnts/portal_vertex/inmask/xy-bounds — then viewconeCheck(gfxobj->drawing_sphere). A view returning OUTSIDE is skipped; if ALL views return OUTSIDE the function returns OUTSIDE_VIEWCONE_ODS without drawing. Slightly richer than the claim (a per-SLICE loop, plus a building_view filter), but fully consistent with "cell contents culled per portal-clipped view".
|
||
|
||
5. "Cull-only, never hard-clip" confirmed: DrawMeshInternal @ 0x0059f360 ignores the BoundingType argument on the built-mesh path and submits the whole constructed mesh (D3DPolyRender::DrawMesh). Bonus port-relevant detail: it dedups parts once-per-frame via CPhysicsPart Get/SetDrawnThisFrame with player-object parts EXEMPT — a part straddling cells draws under the first cell/view that passes, so a faithful port needs the frame-stamp semantics too.
|
||
|
||
ACDREAM SIDE — read the production code and call sites:
|
||
|
||
6. RetailPViewRenderer.cs:439-450 UseIndoorMembershipOnlyRouting: clears entity clip routing (`_entities.ClearClipRouting()`); the comment explicitly names Render::viewconeCheck as retail's mechanism and the character-slicing rationale for not hard-clipping — and no cull substitute exists anywhere (grep "viewcone" across src = comments only, RetailPViewRenderer.cs:371,442).
|
||
|
||
7. The divergence is actually slightly UNDERSTATED: in the indoor per-cell path, DrawCellObjectLists (RetailPViewRenderer.cs:401-426) → DrawEntityBucket (:460-477) builds the landblock entry with lbId = ctx.PlayerLandblockId (:465) AND passes neverCullLandblockId: ctx.PlayerLandblockId (:474) — so WbDrawDispatcher.WalkEntitiesInto's landblock frustum cull (WbDrawDispatcher.cs:593-595) and per-entity AABB frustum cull (:657-666, gated by `entry.LandblockId != neverCullLandblockId` at :662) are BOTH bypassed. The per-cell indoor entity path is gated by cell membership ONLY (EntityPassesVisibleCellGate, WbDrawDispatcher.cs:1816-1835). The claimed "frustum + 5m AABB" (PerEntityCullRadius=5.0f at :208-210) describes the general outdoor path.
|
||
|
||
8. Particles: GameWindow.cs:9553-9580 DrawRetailPViewCellParticles scissors to sliceCtx.Slice.NdcAabb via BeginDoorwayScissor (:9569) with clip distances explicitly disabled (:9568); the :9701-9706 comment confirms particle.vert has no gl_ClipDistance. AABB-of-slice scissor only — the AABB corners outside the actual portal polygon leak, feeding particles-through-walls.
|
||
|
||
9. Port-shape premise holds: the per-cell slice planes already exist at the entity draw site — GetCellSlicesOrNoClip (RetailPViewRenderer.cs:428-437) yields ClipViewSlice(Slot, NdcAabb, Vector4[] Planes) (ClipFrameAssembler.cs:40), currently consumed only by the particle scissor (:423-424), not by the entity draw.
|
||
|
||
REALITY OF THE DIVERGENCE: not behaviorally equivalent and not handled elsewhere. For the ROOT view retail's portal planes ≈ the screen frustum, so acdream's frustum cull is equivalent THERE; for any cell visible through a doorway the slice planes are strictly narrower than the frustum, and acdream has no test at all. User-visibility is amplified by acdream's own #113 shell clipping (RetailPViewRenderer.cs:374-380: outdoor-eye roots clip cell shells per slice via gl_ClipDistance) — a wall clipped away outside the slice no longer depth-occludes the unculled entity behind it, producing the statics/characters-visible-near-apertures class; particles leak at slice-AABB corners; and every non-culled entity is a wasted draw. Severity "medium" is fair today (depth test masks much of it for indoor-eye unclipped roots); it trends toward high once #114 lands pixel-exact indoor clip regions, because the more faithfully shells are clipped, the more the missing object cull shows.
|
||
- blastRadius: Objects in a visible cell draw at full screen extent even when the cell is visible only through a sliver of doorway — the 'statics/characters visible through walls near apertures' class, and wasted draws; particles inherit the same gap (AABB scissor only, no plane clip), feeding the particles-through-walls bug.
|
||
- retailEvidence: Render::viewconeCheck (Ghidra 0x0054c250) tests the object sphere against viewer_world_space.CY plus the active portal view's plane set (portal_npnts loop) and returns OUTSIDE/PARTIAL/INSIDE; DrawCells stage 3 installs Render::PortalList = the cell's portal_view before DrawObjCellForDummies (0x5a4b07), so cell contents are culled per portal-clipped view. Polygons are not hard-clipped — the test is cull-only.
|
||
- acdreamEvidence: RetailPViewRenderer.cs:439-450 UseIndoorMembershipOnlyRouting deliberately clears clip routing for entities (rationale: hard gl_ClipDistance slices characters, which retail does not do — correct observation, but it removed the CULL too); WbDrawDispatcher.cs:208-210/593-595 cull by frustum + 5m AABB only. Particle passes scissor to the slice AABB (GameWindow.cs:9569) with no plane clip (9701-9706 comment).
|
||
- portShape: Port viewconeCheck as a CPU sphere-vs-slice-planes test in the per-cell entity loop (the slice planes already exist in ClipViewSlice.Planes): skip the entity when fully outside every slice of its cell; never hard-clip. Apply the same test to per-cell particle emitters. Small, contained, and retail-faithful — it is a cull, not a clip.
|
||
|
||
### [MEDIUM] weather-gate-player-vs-viewer (adjusted) — Weather pass gating: retail draws weather only when the PLAYER is outside; acdream keys it on the viewer root's seen_outside, so rain draws through doorways while the player is inside
|
||
- correctedClaim: CONFIRMED divergence, corrected port shape. Divergence (as claimed, verified in Ghidra): retail gates the weather pass on the PLAYER being in an outdoor cell — GameSky::Draw @ 0x00506ff0 gate `is_player_outside() || pass==0`, with SmartBox::is_player_outside @ 0x00451e80 = `(player->m_position.objcell_id & 0xffff) < 0x100`; the gate is live on indoor frames because PView::DrawCells @ 0x005a4840 (pc:432719) calls LScape::draw whenever outside_view is non-empty. acdream gates both weather call sites (GameWindow.cs:9535 via renderSky param from 7423/7632, and 7881 via drawSkyThisFrame=renderSky at 7552) on the VIEWER root's seen_outside — so rain draws through the doorway slice (and on player-inside/camera-outside frames) while the player is indoors. CORRECTED port shape: gate the two RenderWeather calls on the retail predicate "player's cell id low word < 0x100" (player not in an EnvCell, false when no player exists) — NOT on `playerRoot is null || playerSeenOutside` as originally proposed, because building interiors have SeenOutside=true, so that predicate stays true inside the inn and would leave the headline bug unfixed. The dome keeps the existing seen_outside-based renderSky gate (matches retail pass-0 behavior).
|
||
- verifier notes: RETAIL (all branch claims re-derived from Ghidra decompiles, not BN pseudo-C): (1) GameSky::Draw @ 0x00506ff0 — outer gate is literally `if ((SmartBox::is_player_outside(smartbox) != 0) || (param_1 == 0))`; the pass-1 body is `else if (LScape::weather_enabled) { render_device->vtbl[+100](this->after_sky_cell); }`. Weather (pass 1) therefore draws ONLY when is_player_outside; the dome/sky-object loop (pass 0) is exempt via `|| pass==0`. (2) SmartBox::is_player_outside @ 0x00451e80 — `return (this->player->m_position.objcell_id & 0xffff) < 0x100` (0 if player null): it keys off the PLAYER physics object's cell (indoor EnvCells have low word >= 0x100), NOT the viewer/camera — the load-bearing player-vs-viewer distinction is genuine, not a naming artifact. (3) LScape::draw @ 0x00506330 — GameSky::Draw(0) before the DrawBlock loop, GameSky::Draw(1) after, gated on weather_enabled. (4) PView::DrawCells @ 0x005a4840 (pc:432707-432719) — on indoor frames with outside_view.view_count > 0 retail calls LScape::draw, so GameSky::Draw(1)'s player gate is live precisely in the doorway-slice scenario: dome + landscape draw through the door, weather is suppressed while the player is in an EnvCell. ACDREAM (read at the cited lines + production call sites): GameWindow.cs:7423 `bool renderSky = viewerRoot is null || rootSeenOutside`, with rootSeenOutside = VIEWER cell SeenOutside (7320; viewer cell selected at 7301-7312 from RetailChaseCamera.ViewerCellId). The indoor doorway slice passes that same renderSky into DrawRetailPViewLandscapeSlice (call site 7624-7634; param at 9472), where it gates BOTH RenderSky (9485-9487) AND RenderWeather (9533-9536). The outdoor post-scene weather call (7874-7882) is gated on `clipRoot is null && drawSkyThisFrame` where drawSkyThisFrame = renderSky (7552) — also viewer-keyed. No player-outside predicate exists on any weather path: playerSeenOutside (7296) feeds only lighting (playerInsideCell 7337 → UpdateSunFromSky 7352), and SkyRenderer.RenderWeather (SkyRenderer.cs:136-144 → shared RenderPass:154) has no internal gate. DIVERGENCE IS REAL: player inside a building interior (seen_outside=true) with the doorway in view → retail draws no weather (is_player_outside=0) while acdream draws the rain/post-scene pass in the doorway slice; likewise player-inside/camera-outside frames draw full-screen weather in acdream but none in retail. Severity medium (cosmetic, weather-only) is fair. ADJUSTMENT: the claimed port shape is wrong in a load-bearing way — gating on `playerRoot is null || playerSeenOutside` (7296) would NOT fix the headline case, because building interiors have SeenOutside=true, so the predicate stays true inside the inn and weather keeps drawing. Retail's predicate is "player's objcell_id low word < 0x100" (player in an outdoor cell; false when player is null), which in acdream terms is the player's CurrCell NOT being an EnvCell (≈ `playerRoot is null` WITHOUT the `|| playerSeenOutside` disjunct), ideally derived from the player's cell id directly so the no-player case suppresses weather like retail. Keeping the dome on the existing renderSky/seen_outside gate matches retail pass-0 (dome ungated within LScape::draw; sealed dungeons never reach LScape::draw because outside_view is empty, which acdream's seen_outside=false reproduces).
|
||
- blastRadius: Cosmetic divergence at building doorways in rain/snow: retail shows no rain through the door while you are inside; we draw the weather cylinder through the slice. Also double-gates differently from the sky dome, which retail draws unconditionally in the landscape pass.
|
||
- retailEvidence: GameSky::Draw (Ghidra 0x00506ff0): pass gate `is_player_outside() || pass==0` (0x507009) — the dome (pass 0) draws even on indoor frames whose outside_view ran LScape::draw, the weather cell (pass 1, after_sky_cell at 0x5070da) requires is_player_outside.
|
||
- acdreamEvidence: DrawRetailPViewLandscapeSlice draws RenderWeather whenever renderSky (GameWindow.cs:9532-9541), and renderSky = viewerRoot null || rootSeenOutside (7423) — viewer-cell, not player-cell, and no is_player_outside equivalent on the weather half.
|
||
- portShape: Split the gate: keep the dome on the seen_outside/outside-view condition (matches retail pass-0), gate the RenderWeather calls (9535 and 7881) on the PLAYER-outside predicate (playerRoot is null || playerSeenOutside is already computed at 7296).
|
||
|
||
### [MEDIUM] unattached-particles-dropped-outdoors (adjusted) — On outdoor-node frames (normal outdoor play post-cutover) emitters with AttachedObjectId==0 are never drawn — the unattached-emitter pass only exists on the clipRoot==null safety path
|
||
- correctedClaim: Post-cutover, acdream's Scene-particle draws on clipRoot!=null frames (all normal in-world frames, outdoor and indoor) are gated by per-frame entity-membership sets AND a non-zero AttachedObjectId (GameWindow.cs:9528-9529, 9575-9576), whereas retail draws every shadow part in every in-view cell unconditionally with no attachment concept (PView::DrawCells 0x005a4840 → DrawObjCell 0x005a1a40 → DrawPartCell 0x005a07a0; emitters always have a parent physobj, makeParticleEmitter 0x0051cd80). However, the specific AttachedObjectId==0 population is EMPTY in production (every spawn path passes a non-zero key; sky emitters use non-Scene passes), so the ==0 exclusion is a LATENT trap (severity low — it will silently eat future world-positioned effects such as lightning #2), not an active regression; also the "legacy filtered branch" at GameWindow.cs:7851-7858 is unreachable (clipAssembly is non-null only when clipRoot!=null), the real safety path being the unfiltered draw at 7863-7867. The behaviorally ACTIVE divergence in the same predicates is the membership-set filter: emitters attached to partition.LiveDynamic entities (ParentCellId==null, InteriorEntityPartition.cs:39-40) belong to neither set and are never drawn on clipRoot!=null frames — that, not the ==0 clause, is the population retail would draw and acdream drops; port shape = route particle drawing off cell membership (the partition buckets) instead of attach-id set intersection, with an explicit bucket for LiveDynamic-attached and (if ever introduced) unattached emitters.
|
||
- verifier notes: RE-CHECKED ACDREAM: (1) Outdoor-node frames really are clipRoot!=null: _outdoorNode is built whenever viewerRoot is null && viewerCellId!=0 (GameWindow.cs:7458-7482), OutdoorCellNode.Build always returns a node (OutdoorCellNode.cs:23-30), clipRoot = viewerRoot ?? _outdoorNode (GameWindow.cs:7497); per the cutover comment (7488-7496) clipRoot==null only pre-spawn/login/legacy camera. (2) On clipRoot!=null frames the only Scene-pass particle draws are the landscape-slice pass (GameWindow.cs:9519-9529: AttachedObjectId != 0 && in _outdoorSceneParticleEntityIds, populated from sliceCtx.OutdoorEntities = partition.Outdoor per RetailPViewRenderer.cs:231+571-573) and the cell pass (GameWindow.cs:9558-9576: AttachedObjectId != 0 && in _visibleSceneParticleEntityIds from the per-cell bucket, RetailPViewRenderer.cs:424). ParticleRenderer.BuildDrawList applies pass+filter per emitter (ParticleRenderer.cs:182-187). So ==0 emitters are indeed never drawn on outdoor-node (and indoor) frames — the structural gate claim is CORRECT. (3) Mechanical correction: the cited "legacy filtered branch at 7856" is DEAD CODE — it requires clipRoot==null && clipAssembly!=null, but clipAssembly is only ever assigned non-null inside the clipRoot!=null branch (only assignments: GameWindow.cs:7501, 7532, 7665); on the real safety path the UNFILTERED global draw at 7863-7867 runs (also admits ==0). (4) BLAST RADIUS REFUTED: no production path spawns a Scene-pass emitter with AttachedObjectId==0. Sole factory ParticleSystem.SpawnEmitter (ParticleSystem.cs:32-64); sole production caller ParticleHookSink.SpawnFromHook always passes the entity key (ParticleHookSink.cs:226-232); key sources are EntityScriptActivator.OnCreate (guards key!=0, EntityScriptActivator.cs:97-98), the 0xF754 wire handler (GameWindow.cs:4974-4985 — guid addresses an object; PhysicsScriptRunner.Play has no zero-guard, PhysicsScriptRunner.cs:120-142, but guid==0 on the wire is not a known ACE behavior), and sky-PES synthetic ids which are SkyPre/PostScene pass (GameWindow.cs:5012-5016) and thus excluded from Scene draws by the pass check. The ==0 predicate filters an empty set today — latent trap (e.g. for future world-positioned lightning, issue #2), not an active invisible-particles regression. RE-CHECKED RETAIL via Ghidra (not BN pseudo-C): PView::DrawCells @ 0x005a4840 final stage iterates cell_draw_list and vtable-dispatches the per-cell object draw unconditionally; RenderDeviceD3D::DrawObjCell @ 0x005a1a40 forwards to DrawPartCell @ 0x005a07a0 which draws EVERY CShadowPart in the cell's shadow_part_list — no attachment filter. Stronger: retail cannot represent an unattached emitter at all — ParticleEmitter::makeParticleEmitter @ 0x0051cd80 null-guards the parent CPhysicsObj, ParticleManager::CreateParticleEmitter @ 0x0051b6c0 takes the parent physobj, and ParticleEmitter owns its own physobj living in cells (acclient.h:52469-52489, fields parent + physobj + parts). Retail draw is purely cell-membership-driven. SIBLING FINDING (the behaviorally active divergence hiding next to the claimed one): the same predicates require the attach id to be IN one of the two membership sets; emitters attached to partition.LiveDynamic entities (server entities with ParentCellId==null, InteriorEntityPartition.cs:35-49) are in neither set, so their effects (e.g. wire PlayScript on a moving player/NPC whose ParentCellId is unset) never draw on outdoor-node frames — that population is non-empty (GameWindow.cs:7813-7823 draws a real LiveDynamic bucket) and is what retail's unconditional cell draw would render. How often live dynamics have null ParentCellId outdoors was not fully settled (open question); entities with an outdoor ParentCellId DO land in partition.Outdoor (InteriorEntityPartition.cs:61-64) and their emitters draw.
|
||
- blastRadius: World-positioned particle effects (any emitter not attached to an entity) silently invisible during normal outdoor gameplay since the cutover; indoor too (per-cell filter also requires a non-zero attach id). A quiet regression class rather than a reported issue — worth a targeted visual check.
|
||
- retailEvidence: Retail draws cell contents (including particle-bearing objects) via DrawObjCell/DrawObjCellForDummies for every in-view cell unconditionally (DrawBlock 0x5a19e6, DrawCells stage 3 0x5a4b0d); there is no attachment-based filter — everything in a cell's shadow list draws.
|
||
- acdreamEvidence: GameWindow.cs:7846 gates the global Scene-particle pass on `clipRoot is null`; the clipRoot!=null replacements both require AttachedObjectId != 0 (slice pass 9528-9529, cell pass 9575-9576); only the legacy filtered branch at 7856 admits AttachedObjectId==0.
|
||
- portShape: Add the unattached-emitter draw to the clipRoot!=null frame (one extra ParticleRenderer.Draw with the `AttachedObjectId == 0` predicate after the slice/cell passes, scissored like the others), or fold unattached emitters into the outdoor bucket's slice pass. One-line predicate change once placed.
|
||
|
||
### [LOW] global-passes-vs-per-cell-interleave (confirmed) — Outdoor composition is global passes (terrain → interiors → entities) instead of retail's per-cell interleave (terrain cell → building+interior → objects → alpha flush) over far-to-near blocks
|
||
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C) for every branch-sensitive element:
|
||
|
||
1. LScape::draw (0x506330, pc:267911-267951): GameSky::Draw(0) → iterate block_draw_list from index mid_width²-1 DOWN to 0, calling RenderDevice vtable DrawBlock per non-null block → weather sky pass. End-first iteration confirmed (esi_2 decrements from esi_1-1 to 0).
|
||
2. LScape::get_block_order (0x504c50, pc:266559-266655): block_draw_list[0] = the viewer's block (0x504c8a), then a ring loop (edi_1 = ring radius 1..max, four quadrant-symmetric writes per step) fills outward. Viewer-first rings confirmed → end-first iteration in draw = far→near at BLOCK granularity. (Within a block, cells iterate in plain array order, NOT distance-sorted — the claim's wording "over far-to-near blocks" is correct.)
|
||
3. RenderDeviceD3D::DrawBlock (0x5a17c0, pc:430021+, claimed pc:430027 — checks out; Ghidra decompile confirms): two per-cell loops. Loop 1: per cell, if in-view and has shadow objects → UpdateObjCell + CShadowPart::insertion_sort. Loop 2: per cell — SetSurfaceArray(terrain), landscape_detail_surface swap (ONLY when side_cell_count==8, i.e. full-LOD block; src_blend=5/dst_blend=6 at 0x5a199b-0x5a19b6) → vtable+0x54 DrawLandCell → vtable+0x58 DrawSortCell → FlushAlphaList(flush) gated on the global float `flush` vs 1.0 (Ghidra: `(flush < 1.0) != (flush == 1.0)`, i.e. flush ≤ 1.0; the global defaults to 0.75 at pc:1106050, so the per-cell flush IS active in the default config).
|
||
4. RenderDeviceD3D::DrawSortCell (0x59f140, Ghidra): if (cell->building) DrawBuilding(building); then DrawObjCell(cell). Exactly "building then objects".
|
||
5. RenderDeviceD3D::DrawBuilding (0x59f2a0, pc:427938-427961): sets outdoor_pview->outdoor_portal_list = building->portals; swaps Render::curr_detail_surface = building_detail_surface at 0x59f2eb (claimed address confirmed) with src_blend=9/dst_blend=6; calls FlushAlphaList(0f) at 0x59f30b BEFORE drawing the building (an extra flush boundary the claim didn't mention); CPhysicsPart::Draw(part,1) → DrawMeshInternal (0x59f360) → BSPTREE::build_draw_portals_only — the conditional portal-poly path; interiors recurse inline via RenderDeviceD3D::DrawPortal (0x59f0e0) → PView::DrawPortal(outdoor_pview) (0x59f109). So "building+interior" within the cell iteration is accurate. Interiors also swap their own environment_detail_surface (RenderDeviceD3D::DrawEnvCell 0x59f170 at 0x59f1c2) — the detail-state divergence covers interiors too, not just buildings.
|
||
|
||
ACDREAM SIDE — all cited lines verified against production code:
|
||
1. GameWindow.DrawRetailPViewLandscapeSlice (src/AcDream.App/Rendering/GameWindow.cs:9465-9551; note: GameWindow.cs lives under Rendering/): sky (9486) → ALL terrain in one call `_terrain?.Draw(...)` (9496) → ALL outdoor entities in ONE WbDrawDispatcher.Draw over sliceCtx.OutdoorEntities (9503-9511) → outdoor-entity particles (9523) → weather (9535). Global passes confirmed.
|
||
2. TerrainModernRenderer.Draw (src/AcDream.App/Rendering/TerrainModernRenderer.cs:206-295): builds one DEIC array over every frustum-visible slot and issues a single glMultiDrawElementsIndirect (288-292). Claim's ":206-299" checks out.
|
||
3. Interiors drawn AFTER the landscape slice: RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:93-106) — DrawLandscapeThroughOutsideView (93) then DrawEnvCellShells (104-105) then DrawCellObjectLists (106). Claimed :104-106 confirmed.
|
||
4. WbDrawDispatcher sorts opaque front-to-back / translucent back-to-front per invocation (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1203-1204; comparators :1436-1446, cull-mode-major then distance asc/desc). Claimed :1199-1204 confirmed (1197-1202 is the comment).
|
||
5. No detail-surface analog exists anywhere in src/ — grep for detail_surface|DetailSurface|detail_tiling|DetailTiling over src/ returns zero hits. The building/landscape/environment micro-detail overlay system is wholesale absent, confirming that half of the blast radius.
|
||
|
||
DIVERGENCE IS REAL and not behaviorally equivalent in the abstract: retail = per-block far→near, per-cell terrain→building(+inline portal-recursed interiors)→objects→alpha-flush; acdream = global terrain MDI → global outdoor-entity dispatch → all interior shells → per-cell interior objects. Severity "low" is defensible: opaque ordering is fully masked by the depth buffer, no named issue (#99/#108/#109/#113/#114) is attributable to this divergence, and the KEEP-listed bindless-MDI architecture rules out a literal per-cell draw port anyway.
|
||
|
||
REFINEMENTS (recorded, not verdict-changing):
|
||
(a) The claim's concrete mis-order example ("alpha terrain/water vs building alpha at cell boundaries") is UNVERIFIED and likely moot — TerrainModernRenderer has no translucent/blend path at all (single opaque MDI). The verified concrete translucency consequence is sharper: translucent OUTDOOR entities are blended at GameWindow.cs:9508 BEFORE interior shells/objects exist in the framebuffer (RetailPViewRenderer.cs:104-106), inverting retail's order at doorways — a translucent outdoor object in front of a door aperture blends against sky/terrain instead of the interior behind it. Latent artifact class, not currently reported; consistent with severity low.
|
||
(b) Retail has an additional alpha-flush boundary the claim missed: FlushAlphaList(0f) inside DrawBuilding (0x59f30b) before every building draw, and the per-cell flush is conditional on the global flush ≤ 1.0 (default 0.75).
|
||
(c) acdream's INTERIOR side already does a per-cell far→near interleave (DrawEnvCellShells iterates IndoorDrawPlan.ShellPass per cell, RetailPViewRenderer.cs:382-394; DrawCellObjectLists iterates OrderedVisibleCells in reverse per cell, :408-425) — the global-pass divergence is specifically the OUTDOOR composition plus the outdoor-before-interiors pass ordering.
|
||
(d) The missing detail-surface system is real but is a standalone visual-fidelity gap (retail swaps it for terrain 0x5a199b, buildings 0x59f2eb, AND interiors 0x59f1c2) — it deserves its own low/polish line item independent of composition order, since it could be added to the MDI architecture without per-cell draws.
|
||
|
||
PORT SHAPE judgment: agreed — no correctness port needed; depth buffering substitutes for the opaque interleave, and a faithful translucency fallback (an alpha-flush/sort boundary keyed per building, or simply documenting the doorway translucent-entity case as the trigger to file) does not require abandoning the KEEP-listed MDI pipeline.
|
||
- blastRadius: Mostly masked by the depth buffer; visible only in translucent ordering (retail's per-cell FlushAlphaList sorts alpha against the just-drawn cell; our two-pass alpha-test + per-draw sort can mis-order alpha terrain/water vs building alpha at cell boundaries) and in the absence of the per-building detail-surface state retail swaps in (building_detail vs landscape_detail tiling, 0x59f2eb vs 0x5a199b).
|
||
- retailEvidence: DrawBlock (pc:430027): per cell DrawLandCell → DrawSortCell(building, objects) → FlushAlphaList(flush); LScape::draw iterates block_draw_list end-first (far→near, get_block_order 0x504c50 builds viewer-first rings).
|
||
- acdreamEvidence: GameWindow.cs:9494-9512 draws ALL terrain (TerrainModernRenderer.Draw, one MDI over every visible slot, TerrainModernRenderer.cs:206-299) then ALL outdoor entities; building interiors drawn afterward in DrawEnvCellShells/DrawCellObjectLists (RetailPViewRenderer.cs:104-106); alpha = two-pass alpha-test model (CLAUDE.md KEEP-list), opaque front-to-back / transparent back-to-front sort (WbDrawDispatcher.cs:1199-1204).
|
||
- portShape: No port needed for correctness given the KEEP-listed bindless MDI architecture — depth buffering substitutes for the interleave. File only if a concrete translucency mis-order is observed; the faithful fallback is a per-building alpha flush boundary (sort key extension), not a return to per-cell draws.
|
||
|
||
## OPEN QUESTIONS
|
||
|
||
- Where does retail draw the TEXTURED portal-filling quad (the visible door/window filling)? Verified: the shell pass draws the constructed mesh with no portal logic (DrawMeshInternal 0x59f3f4 → D3DPolyRender::DrawMesh 0x59d4a0), the portals-only pass uses only modes 1 (far punch) and 2 (flood, no poly) (0x59f3cc/0x59f3d9), and DrawPortal's mode-3 fail-fill draws the poly INVISIBLE (maxZ2 bit1 ⇒ alpha 0). If the built MeshBuffer excludes node.Portals polys (consistent with e223325's node.Polygons finding), nothing in the traced built-mesh path ever draws the filling textured — yet doors/windows are visibly filled in retail. Candidates not yet traced: MeshBuffer/constructed-mesh build including portal polys as subsets with a runtime skip I did not find; the legacy non-built-mesh BSP draw path (RenderDeviceD3D::DrawMesh 0x005a0860 / BSPNODE draw walk); or the maxZ1/maxZ2 globals being reconfigured at startup from the registry ('RenderD3D.*' strings near 0x7e5594) so the fail-fill is not invisible in practice. This is THE blocking question for the portal-poly-conditional-draw port and needs a dedicated trace (Ghidra xrefs on MeshBuffer construction + cdb on maxZ1/maxZ2 at runtime).
|
||
- Does CCellStruct.polygons (the EnvCell shell submitted with planeMask=0xffffffff at pc:427922) include the cell-side portal polygons, or are cell portals (CCellPortal) excluded from the shell the way building portals are excluded from node.Polygons? Determines whether our EnvCell meshes need the same portal-poly separation as building GfxObjs for #109's indoor case.
|
||
- CShadowPart::insertion_sort's exact key (assumed viewer distance from the UpdateViewerDistance calls at 0x5a17c0/0x59f2bc) and whether CShadowPart::draw itself calls Render::viewconeCheck per part or relies on a BoundingType computed earlier — I did not decompile CShadowPart::draw. Affects only the fidelity note on the entity-cull divergence, not its existence.
|
||
- The precise fragment source of #108's grass (which terrain triangles produce the sweeping fragments when the eye is below outdoor terrain): terrain inherits the frame's back-face cull (GameWindow.cs:7162-7163, TerrainModernRenderer sets no cull state of its own), so under-surface fragments should be culled; the structural divergences (AABB/8-plane clip slop + clear-after-slices ordering + no fence) are the named suspects, but a RenderDoc capture of one #108 frame is needed to pin which one paints the visible grass.
|
||
- DrawPortal modes: is there any runtime path that calls build_draw_portals_only / DrawPortal with mode 3 (the fail-fill mode) — e.g. a degrade/option-driven variant of CPhysicsPart::Draw — or is mode 3 dead code in the 2013 client? Ghidra xrefs on the thunk found no third call site, but virtual dispatch may hide one.
|
||
- Retail's CEnvCell 'GetDrawnThisFrame' guard in DrawEnvCell (0x59f17e) means a cell drawn through multiple views draws its shell only ONCE (first view's clip) — seemingly at odds with the per-view setup_view loop in DrawCells stage 2. Whether the guard is per-view-stamped (reset by setup_view) or genuinely once-per-frame changes how our per-slice shell loop (RetailPViewRenderer.cs:388-393, draws once PER SLICE) should be shaped; not yet traced into SetDrawnThisFrame/num_view interaction.
|
||
- forceClear (0x8ed824, init 0) and the portalsDrawnCount gate: confirm forceClear is debug/registry-only so the production behavior is exactly 'clear Z iff at least one fence/landscape portal poly drew this frame' — relevant to porting the clear gate faithfully.
|