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>
66 KiB
AREA 1 — GfxObj draw path (per-part mesh draw, drawing-BSP portal polys, clipping, degrades, viewcone)
RETAIL
RETAIL'S GFXOBJ DRAW IS A FLAT MESH DRAW, NOT A PER-FRAME BSP WALK — WITH ONE EXCEPTION: PORTAL POLYS.
-
LOAD TIME (the big surprise). CGfxObj::InitLoad (Ghidra 0x005346b0, pc:318765-318789) does two things when running in the live client: (a) BSPTREE::RemoveNonPortalNodes(drawing_bsp) (Ghidra 0x0053a040) — it DELETES every non-portal node from the drawing BSP, leaving only a skeleton chain of portal-bearing nodes; (b) D3DPolyRender::ConstructMesh(this, &constructed_mesh) (wrapper Ghidra 0x0059ea90) which flattens the GfxObj into a D3DX mesh ("constructed_mesh", a MeshBuffer) from the FULL polygon dictionary: ConstructMesh(num_surfaces, m_rgSurfaces, &vertex_array, num_polygons, polygons, 1.0, false, out) (pc:427543). The inner ConstructMesh (Ghidra 0x0059dfa0) iterates ALL CPolygons (stride 0x30, struct acclient.h:31855-31869: vertices/vertex_ids/poly_id/num_pts/stippling/sides_type/pos+neg_uv_indices/pos_surface/neg_surface/plane), batching triangles per pos_surface/neg_surface (pc:426842-426871); I found NO portal-poly or stippling-based skip in it — portal fill quads land in the constructed mesh too. So retail itself is "flatten at load, draw the flat mesh per frame" — the same architecture acdream/WB uses. The runtime drawing BSP exists ONLY to drive portal polygons. EnvCells get the same treatment at CEnvCell::UnPack (ConstructMesh call at pc:311085, Ghidra 0x0052d875, detail-tiling 3.0).
-
PER-FRAME CHAIN FOR AN ORDINARY OBJECT. CPhysicsPart::Draw(part, mode) (Ghidra 0x0050D7A0, pc:274964): skip if hidden (draw_state & 1) or already drawn this render frame (m_current_render_frame_num == RenderDevice frame stamp; bypassed when mode!=0). Pick LOD: deg_level clamped to degrades->num_degrades else 0; mesh = gfxobj[deg_level]. Set part material, part surface array, object scale, then virtual RenderDevice::DrawMesh(mesh, &draw_pos, mode!=0) (vtable +0x70 = RenderDeviceD3D::DrawMesh, vtable dump pc:1037075). RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860, pc:429245): if Render::PortalList == NULL (plain outdoor view) → one Render::viewconeCheck(gfxobj->drawing_sphere); skip if OUTSIDE. If PortalList != NULL (we are inside a portal-view context — PView::DrawCells sets PortalList=pview at pc:432718, and the per-cell object pass sets PortalList = that cell's view stack at pc:432877) → LOOP over every view: Render::set_view(view, i) (Ghidra 0x0054d0e0, pc:343750), viewconeCheck per view, draw per passing view (building_view filter). viewconeCheck (Ghidra 0x0054c250) is a SPHERE test of the object's drawing_sphere against the CURRENT VIEW's plane set (portal_vertex/portal_npnts installed by set_view): returns OUTSIDE / PARTIALLY_INSIDE / ENTIRELY_INSIDE. Then RenderDeviceD3D::DrawMeshInternal (Ghidra 0x0059f360, pc:427965-428002): non-player parts dedup via GetDrawnThisFrame/SetDrawnThisFrame; if gfxobj->use_built_mesh and mode==0 → D3DPolyRender::DrawMesh(gfxobj, constructed_mesh) (pc:427999 → 0x0059d790 → inner 0x0059d4a0). The inner draw iterates SURFACE BATCHES, routing alpha/translucent/clipmap batches to the deferred AlphaList per s_AlphaDelayMask (config string "Alpha=2, Translucent=4, ClipMap=8", pc:1037103) and hardware-drawing the rest via RenderMeshSubset. CRITICAL DETAIL: with global skipNoTexture (data-section default 1, pc:1105971 @0x00820e30), a surface batch whose CSurface type has neither Base1Image (0x2) nor Base1ClipMap (0x4) — i.e. an UNTEXTURED/SOLID-COLOR surface — is SKIPPED whenever RenderDeviceD3D::ObjBuildingOrBuildingPart==1 (building shell pass) or the cell flag arg is set (Ghidra 0x0059d4a0 at 0x0059d4f1; cell call site passes 1 at pc:427905); plain objects still draw solid batches. There is NO per-polygon geometric clipping anywhere on this mesh path — meshes are sphere-culled per view and hardware-frustum-clipped, exactly as claimed (Q5 CONFIRMED).
-
BUILDINGS — THE TWO-PASS PORTAL DISCIPLINE (Q2, the door-vanish answer). RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:427938-427961): set outdoor_pview->outdoor_portal_list = building->portals (CBldPortal list, struct acclient.h:32094); CPhysicsPart::UpdateViewerDistance(shell part) — buildings DO degrade; FlushAlphaList; then CPhysicsPart::Draw(part, 1) — the PORTAL-ONLY draw — then ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part, 0) — the shell constructed mesh (with solid batches skipped). The mode-1 path (DrawMeshInternal pc:427988-427996) runs BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (drawing_bsp, 2) (Ghidra 0x00539860; recursion BSPNODE 0x0053c100 / BSPPORTAL 0x0053d870). The walk classifies the VIEWER's eye against each node's splitting plane (dot(N, viewpoint)+d vs ±epsilon → front/back/on, node tags 'PORT'/'LEAF' 0x504f5254/0x4c454146) and, at each BSPPORTAL node (struct acclient.h:57768-57772: num_portals + CPortalPoly** in_portals; CPortalPoly = {portal_index, CPolygon* portal} acclient.h:39075-39079, wired to the SAME polygon dictionary at unpack, BSPPORTAL::UnPackPortal pc:327247), submits each portal poly via vtable DrawPortal (+0x4c, pc:1037066) = RenderDeviceD3D::DrawPortal (Ghidra 0x0059f0e0) → PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895-433933).
THE GATE — PView::ConstructView(CBldPortal*, CPolygon*, int, pass) (Ghidra 0x005a59a0, pc:433827, Ghidra-confirmed): (a) SIDE TEST: compute which side of the portal polygon's plane the eye is on; CBldPortal::portal_side==0 requires POSITIVE, else NEGATIVE — wrong side ⇒ fail; (b) CLIP TEST: GetClip clips the portal polygon against the CURRENT view (screen frustum or accumulated portal view); fully clipped away ⇒ fail; (c) LOAD/VISIBILITY TEST: CEnvCell::GetVisible(other_cell_id) (Ghidra 0x0052dc10 — a lookup in the global visible_cell_table hash of loaded cells) must return the cell beyond ⇒ else fail; (d) Render::copy_view pushes the new through-portal view ⇒ else fail. ON SUCCESS with pass!=2 it calls D3DPolyRender::DrawPortalPolyInternal(poly, pass==1) and with pass!=1 recurses into the cell beyond. Back in PView::DrawPortal: success & pass!=1 ⇒ PView::DrawCells (draw the interior through the aperture); failure ⇒ draw NOTHING for passes 1/2 (the pass==3 fallback at pc:433914 has no live caller — the only DrawPortal call sites are the BSP walk with passes 1/2, pc:326951/326992).
WHAT "DRAWING THE PORTAL POLY" ACTUALLY IS: DrawPortalPolyInternal (Ghidra 0x0059bc90, pc:424490) is an INVISIBLE DEPTH-CONTROL DRAW, not a textured draw. With shipped defaults maxZ1=7 / maxZ2=6 (data section pc:1105964-1105965 @0x00820e14/18): vertex alpha computes to 0x00 (fully transparent under SRCALPHA/INVSRCALPHA), depth test ALWAYS, z-write ON; flag=true (pass 1, portal OPENS) forces Z to the far plane (0.99999988) — a "z-punch" that erases the depth buffer inside the aperture so the interior cells drawn in pass 2 land cleanly; flag=false writes the portal plane's OWN depth — a "z-seal" used by PView::DrawCells (Ghidra 0x005a4840, pc:432709-432889) on portals leading OUTSIDE (other_cell_id==0xFFFF, pc:432785-432786) so geometry beyond an unopened exit is depth-occluded. DrawCells also: draws the landscape through outside views (LScape::draw, pc:432719), conditionally CLEARS depth when seals were drawn (portalsDrawnCount, pc:432725-432732), draws cells BACK-TO-FRONT (reverse cell_draw_list) per view via DrawEnvCell, then per cell sets PortalList to the cell's views and draws cell objects (DrawObjCellForDummies, pc:432878). So: doors/windows/stair-apertures are all the SAME mechanism — an open portal punches depth and shows the room; an unopened one draws nothing (building passes) or a z-seal (cell exit portals); the visually-textured "fill" appearance comes only from the constructed mesh, where the skipNoTexture rule decides whether a fill batch (solid vs textured surface) draws at all.
-
PER-POLY CLIPPING / planeMask (Q3). Only the LEGACY non-built-mesh cell path does exact polygon clipping: RenderDeviceD3D::DrawEnvCell's slow path submits every cell polygon into Render::PolyList with planeMask=0xffffffff (pc:427913-427931). Render::set_view (Ghidra 0x0054d0e0) installs the active view's edge-plane array (portal_vertex, portal_npnts) and portal_inmask = (1<<(npnts+1))-1. ACRender::polyClipFinish (Ghidra 0x006b6d00, pc:702749) shifts the planeMask by (0x1e - npnts) and tests one bit per view plane as it walks them — bit set ⇒ geometrically clip the poly against that plane, bit clear ⇒ skip. planeMask=0xffffffff = "clip against every plane of the current portal view" (the conservative full clip); DrawPortalPolyInternal passes mask 0 (clip nothing — it draws depth-only with DEPTHTEST_ALWAYS, sloppy by design, because the shell mesh drawn afterwards repairs over-punched depth). Constructed-mesh draws never see planeMask.
-
DEGRADES (Q4). CPhysicsPart::UpdateViewerDistance (Ghidra 0x0050E030) computes eye distance to the part's sort_center and calls GfxObjDegradeInfo::get_degrade(dist/scale, °_level, °_mode) for EVERY part with a degrade table EXCEPT parts of the local player (player iid check ⇒ deg_level=0, full detail). Call sites: per-object draws via CPhysicsObj::UpdateViewerDistance (0x0051827a/0x005182b3), cell-object updates (UpdateObjCell 0x005a0712/0x005a073c), recursive part arrays (0x00510c53), AND DrawBuilding explicitly (0x0059f2bc). So distance LOD applies to building shells, statics, doors, NPCs — everything but the player. CPhysicsPart::LoadGfxObjArray (0x0050DCF0) loads the full gfxobj[] degrade array per part at setup; Draw then indexes gfxobj[deg_level].
ACDREAM
ACDREAM'S EQUIVALENT CHAIN.
-
FLATTEN AT DECODE (matches retail's load-time ConstructMesh in shape). ObjectMeshManager.PrepareGfxObjMeshData (src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1012) iterates the FULL gfxObj.Polygons dictionary (line 1040) — portal fills included — batching by PosSurface/NegSurface with WB's stippling interpretation (NoPos suppresses the positive side at :1046; negative side per Negative/Both/NoNeg+CullMode at :1052-1058). DIVERGENCE INSIDE THE FLATTEN: solid surfaces are PROMOTED TO VISIBLE 32×32 solid-color textures and drawn (isSolid → TextureHelpers.CreateSolidColorTexture, :1075-1088) — retail SKIPS untextured batches on building shells and cells (skipNoTexture rule). The #113 filter experiment and its revert are memorialized in the comment block at :1020-1039. The EnvCell twin PrepareCellStructMeshData (:1370) flattens cellStruct.Polygons with NoPos/NoNeg suppression (:1393-1402) and the same solid-color promotion (:1430).
-
DRAW. WbDrawDispatcher.Draw (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:702/:1403) walks landblock entries → entities → MeshRefs: per-landblock AABB frustum cull (:593-595), per-entity AABB frustum cull for statics (:657-665), per-entity classification cache, palette-hash memoization (:983), part-matrix composition (:1056+), then groups into indirect commands — opaque front-to-back sorted (camera position for sort :733, :1175, :1206), transparent after — and submits via glMultiDrawElementsIndirect in CullMode runs with uDrawIDOffset (:1478-1490). Visibility gating beyond the camera frustum is the clip-routing SLOT system (UseIndoorMembershipOnlyRouting / clip-slot resolution :314-:488): each instance is routed to the clip slice of its MEMBERSHIP cell or to OutsideView, or culled when its cell is not visible. There is NO per-portal-view loop, NO per-view sphere re-cull, and no equivalent of retail's PortalList iteration; entities draw at most once per frame against the single camera frustum.
-
PORTAL POLYS ARE NEVER DRAWN — only used as visibility math. PortalVisibilityBuilder/PortalProjection consume portal polygons to build clip regions and planes (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:189-194/:413-416, PortalProjection.cs:54, CellVisibility.cs:56-74, GameWindow.cs:5708-5804). RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-110) orchestrates the flood (R-A2 per-building floods :118+), draws landscape through outside views, then shells (DrawEnvCellShells :346+, gl_ClipDistance per-slice clip enabled for OUTDOOR roots only — indoor roots draw unclipped pending #114, :399-410), then cell objects unclipped (comment :395-398 — deliberately matching retail's sphere-cull-not-hard-clip for meshes). The retail z-seal hook EXISTS but is DEAD: ctx.DrawExitPortalMasks is invoked (:95/:204/:325-341) yet declared nullable (:497/:534/:558) and never assigned by any production caller (repo-wide grep: no assignment). There is no z-punch (pass-1) equivalent anywhere, and no conditional portal-poly draw of any kind.
-
DEGRADES. GfxObjDegradeResolver.TryResolveCloseGfxObj (src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:105-143) always returns Degrades[0] (close detail; the doc comment at :56-60 acknowledges no distance plumbing). Its ONLY production call site is GameWindow.cs:2610, gated by _options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup) (GameWindow.cs:2605); the predicate (GameWindow.cs:302-313) matches only 34-part humanoid setups with null-part sentinels. Everything else — buildings, doors, statics, scenery, non-humanoid creatures — renders the BASE GfxObj id forever (WbDrawDispatcher trusts MeshRefs, comment :987-992; the scenery DIDDegrade reads at GameWindow.cs:5424-5428 are diagnostic-only). No per-frame deg_level selection exists.
-
BUILDING SHELLS are WorldEntities tagged IsBuildingShell hydrated from LandBlockInfo (GameWindow.cs:5258-5267), drawn through the same dispatcher path as any static; EnvCells render through EnvCellRenderer, not as entities (GameWindow.cs:5520-5530 comment, src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs).
DIVERGENCES
[CRITICAL] portal-poly-conditional-pass-missing (adjusted) — No per-frame portal-poly pass: retail's z-punch / z-seal / ConstructView gate on building+cell portal polys is entirely absent
- correctedClaim: CONFIRMED CORE (critical): retail runs a per-frame portal-poly depth pass that acdream entirely lacks. RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0) draws every building in two passes: first a portals-only walk of the load-time-stripped drawing BSP (RemoveNonPortalNodes 0x0053A040 in CGfxObj::InitLoad 0x005346B0; walk via build_draw_portals_only 0x00539860/0x0053c100 and BSPPORTAL::portal_draw_portals_only 0x0053d870, run twice — sub-pass 1 then 2 — from DrawMeshInternal 0x0059f360), submitting each BSPPORTAL.in_portals entry (acclient.h:57768) through RenderDeviceD3D::DrawPortal (0x0059F0E0) -> PView::DrawPortal (0x005A5AB0) -> the PView::ConstructView gate (0x005a59a0: eye-side vs CBldPortal::portal_side, GetClip vs current view, CEnvCell::GetVisible(other_cell_id), copy_view push). Gate success on sub-pass 1 ⇒ DrawPortalPolyInternal(poly,true) = invisible far-plane z-punch (mode maxZ1=7: alpha 0, DEPTHTEST_ALWAYS, z-write, z=0.99999988); success on sub-pass 2 ⇒ view recursion + DrawCells through the portal; failure ⇒ nothing. PView::DrawCells (0x005A4840) seals exit portals (other_cell_id==-1) with DrawPortalPolyInternal(poly,false) = invisible own-depth z-write (mode maxZ2=6). Acdream has zero of this machinery: no z-punch (only ColorMask in src/ is a state restore, GLStateScope.cs:208), DrawExitPortalMasks declared and invoked (RetailPViewRenderer.cs:325-341, :497/:534/:558) but never assigned at either production site (GameWindow.cs:7604-7663, :7781-7798). This is the missing depth discipline feeding #114 and the far-door residuals (#109). CORRECTION (the #113 half is reattributed): the per-frame pass is invisible-by-construction (alpha forced 0 in both default modes), so it does NOT explain doors/windows being visible — retail's visible portal-fill geometry comes from the unconditional flattened shell (ConstructMesh 0x0059ea90 consumes the full polygons array, no per-poly skip), same as acdream's flatten. The doors-visible vs phantom-stair-ramp-invisible split is instead produced by a STATIC retail mechanism the claim denied could exist: D3DPolyRender::DrawMesh (0x0059d4a0) skips untextured mesh subsets (CSurface.type & 6 == 0, i.e. no Base1Image/Base1ClipMap) when skipNoTexture=1 (default) and ObjBuildingOrBuildingPart=1 (set by DrawBuilding around the shell pass). Acdream instead draws those solid-color batches via synthesized 32x32 color textures (ObjectMeshManager.cs:1075-1088). So "no static poly filter can be correct" is wrong as stated: a static per-POLY BSP-reference filter (
e46d3d9) is wrong, but retail's own visible-geometry filter is static — per-SURFACE texture presence, building-scoped. Port shape: (1) per-frame ConstructView-gated punch pass + exit-portal own-depth seal, preserving the punch-all-then-draw-interiors two-sub-pass order, never drawing fills visibly from that pass (correct as claimed); (2) ADD the building-scoped untextured-batch skip as the actual #113 visible-geometry fix; (3) pending dat verification that the stair-aperture fills are solid-color while door/window fills are image-textured. - verifier notes: RETAIL SIDE — re-derived from Ghidra (127.0.0.1:8081), every load-bearing branch confirmed against the live decompile, not BN pseudo-C:
- CONFIRMED two-pass DrawBuilding (Ghidra 0x0059f2a0): sets outdoor_pview->outdoor_portal_list = building->portals, FlushAlphaList, then CPhysicsPart::Draw(part,1) [portal pass] followed by ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part,0) [shell pass].
- CONFIRMED dispatch: CPhysicsPart::Draw (0x0050D7A0) -> render-device vtable+0x70 = RenderDeviceD3D::DrawMeshInternal (0x0059f360): portals_only=true runs BSPTREE::build_draw_portals_only(drawing_bsp,1) THEN (drawing_bsp,2) — TWO sub-passes — and returns without touching the mesh; portals_only=false draws constructed_mesh via D3DPolyRender::DrawMesh.
- CONFIRMED walk: build_draw_portals_only 0x00539860 (BSPTREE) / 0x0053c100 (BSPNODE) / 0x0053d870 (actually named BSPPORTAL::portal_draw_portals_only, minor naming nit). Plane-side recursive walk submits in_portals[i] (BSPPORTAL {num_portals; CPortalPoly** in_portals} verified at acclient.h:57768-57772) via vtable+0x4c = RenderDeviceD3D::DrawPortal (0x0059F0E0) -> PView::DrawPortal(outdoor_pview,...).
- CONFIRMED gate, PView::ConstructView(CBldPortal) (Ghidra 0x005a59a0): (a) eye-side-of-plane (dot(viewpoint,N)+d vs ±F_EPSILON) must match CBldPortal::portal_side (portal_side==0 requires POSITIVE, else NEGATIVE); (b) GetClip against clip_view, null clip ⇒ fail; (c) CEnvCell::GetVisible(other_cell_id) (0x0052DC10) must return a cell; (d) Render::copy_view push onto the cell's portal_view stack. Success: pass!=2 ⇒ DrawPortalPolyInternal(poly, pass==1); pass!=1 ⇒ recurse ConstructView(cell, other_portal_id). PView::DrawPortal (0x005A5AB0): success + pass!=1 ⇒ DrawCells(this,1); failure ⇒ nothing for passes 1/2 (pass 3 ⇒ DrawPortalPolyInternal(poly,false)). Claim's "DrawPortalPolyInternal(poly, pass==1) + DrawCells" is a compression: sub-pass 1 = punch only, sub-pass 2 = view push + recursion + DrawCells (all punches land BEFORE any through-portal interior drawing).
- CONFIRMED z-punch/z-seal semantics, DrawPortalPolyInternal (0x0059bc90): mode word = maxZ1 (arg true) / maxZ2 (arg false); defaults maxZ1=7, maxZ2=6 verified in the data segment (0x00820e18/0x00820e14; pc:1105964-65 region). SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=bit2 → on for both); vertex z forced to 0.99999988 (far plane) when bit0 (mode 7) else own z/w (mode 6); alpha bit = ~(mode<<30)&0x80000000 = 0 for BOTH default modes — i.e. both draws are INVISIBLE (alpha-0 SRCALPHA/INVSRCALPHA), depth-only. (poly,true)=far-plane punch, (poly,false)=own-depth seal — exactly as claimed.
- CONFIRMED exit-portal seal, PView::DrawCells (0x005A4840): per cell view, portals with other_cell_id == -1 ⇒ DrawPortalPolyInternal(poly,false). Also clears Z (vtable+0x2c flag 4) when portalsDrawnCount!=0.
- CONFIRMED load-time strip: BSPTREE::RemoveNonPortalNodes (0x0053A040) called from CGfxObj::InitLoad (0x005346B0, xref 0x005346c6), before ConstructMesh builds the flattened shell.
ACDREAM SIDE — all cited locations verified: ObjectMeshManager.cs:1041 iterates the FULL gfxObj.Polygons dictionary into the flattened batch mesh (e46d3d9 filter reverted, comment block :1020-1040); portal polys otherwise used only as clip math (PortalVisibilityBuilder.cs:186-199, PortalProjection.cs:54-62); DrawExitPortalMasks hook invoked (RetailPViewRenderer.cs:95, :204 -> :325-341) but null-guarded at :331 and NEVER assigned — both production context constructions (GameWindow.cs:7604-7663 DrawInside; :7781-7798 DrawPortal) omit it; repo-wide grep for DrawExitPortalMasks\s*= in src/ returns nothing. No z-punch exists: the only ColorMask reference in src/ is a state-restore (Wb/GLStateScope.cs:208); no DepthFunc(Always) draw anywhere.
THE CORE DIVERGENCE IS REAL AND CRITICAL: retail runs a per-frame, ConstructView-gated, depth-only portal-poly pass (punch + through-portal DrawCells + exit-portal seal) before every building shell draw, and acdream has none of it. The #114 / #109 / outside-looking-in depth-discipline blast radius stands.
WHY ADJUSTED — the #113 visible-geometry attribution does not survive: (a) DrawPortalPolyInternal NEVER draws visible color at default settings (alpha forced 0 in both modes 7 and 6), so the per-frame pass cannot be what makes doors/windows "usually visible" — it is depth-only by construction. (b) Retail's visible portal-fill geometry comes from the UNCONDITIONAL flattened shell: ConstructMesh(CGfxObj) (0x0059ea90) passes the FULL num_polygons/polygons array (loaded verbatim from the dat by CGfxObj::Serialize 0x00534970), and the low-level builder (0x0059dfa0) has no per-poly skip in its triangle-count loop — every dictionary poly is in the constructed mesh, same as acdream's flatten. (c) The doors-visible vs stair-ramp-invisible split is instead plausibly produced by a STATIC per-surface mechanism the claim missed: D3DPolyRender::DrawMesh (0x0059d4a0; BN pc:426064-426074 agrees) skips any mesh subset whose CSurface lacks the texture-image bits (type & 6 = Base1Image|Base1ClipMap) when skipNoTexture=1 (default, data 0x00820e30) — and the draw-anyway escape requires ObjBuildingOrBuildingPart==0, which DrawBuilding sets to 1 around the shell pass. So untextured (solid-color) batches of a BUILDING shell are never drawn in retail. acdream has no equivalent: ObjectMeshManager.cs:1075-1088 synthesizes a 32x32 solid-color texture for Base1Solid surfaces and draws them. This contradicts the claim's "no static poly filter can be correct": retail itself ships a static building-scoped per-SURFACE (texture-presence) filter for the visible-geometry side; what cannot be correct is e46d3d9's static per-POLY BSP-reference filter. The punch (depth-only, drawn BEFORE the shell) cannot hide an opaque textured fill drawn afterward, so the conditional pass mechanically CANNOT explain door/window visibility — only the surface-type skip can explain ramp invisibility.
PORT-SHAPE IMPACT: the claimed port (per-frame ConstructView-gated punch pass + DrawExitPortalMasks as own-depth seal, never drawing fills visibly from that pass) remains correct and necessary for depth discipline, with two amendments: preserve retail's two-sub-pass ordering (all punches, then all through-portal interior draws), and ADD the building-scoped untextured-batch skip (skipNoTexture / type&6 / ObjBuildingOrBuildingPart equivalent) as the actual #113 fix for visible geometry.
OPEN QUESTION (falsifiable test of the corrected #113 attribution): dat-verify that the meeting-hall stair-aperture fill polys {0,1} reference solid-color (no Base1Image/Base1ClipMap) surfaces while the Holtburg door/window fill quads reference image-textured surfaces — extend the e223325 audit with surface types. Also unresolved: precisely which polys e46d3d9's filter removed that the user perceived as "doors disappearing" (door-frame polys on building models vs door-panel polys on door-weenie GfxObjs) — the filter applied to ALL GfxObjs, not just buildings.
- blastRadius: This is the door-vanish ↔ phantom-staircase pair (#113): acdream draws ALL portal fill quads unconditionally as textured/solid-colored mesh geometry (phantom stair ramps visible; e46d3d9's static filter removed doors/windows too and had to be reverted
124c6cb— the dat side proved ine223325that no static filter can be right). It is also the missing depth discipline for outside-looking-in: without the pass-1 z-punch and the pass-2 through-portal interior draw being fenced by ConstructView's gate, and without the z-seal on exit portals, interiors/exteriors rely on acdream's separate flood plumbing alone — feeding #114 (indoor clip-region quality) and the residual far-door artifacts (#109). - retailEvidence: DrawBuilding two-pass: portal-only BSP walk then shell mesh (Ghidra 0x0059f2a0, pc:427938-427961). Walk: BSPTREE/BSPNODE/BSPPORTAL::build_draw_portals_only (Ghidra 0x00539860/0x0053c100/0x0053d870) submits BSPPORTAL.in_portals (acclient.h:57768-57772) via RenderDeviceD3D::DrawPortal (pc:1037066) → PView::DrawPortal (0x005a5ab0, pc:433895). GATE = PView::ConstructView(CBldPortal) (Ghidra 0x005a59a0, Ghidra-confirmed): viewer-side-of-plane vs CBldPortal::portal_side, GetClip against current view, CEnvCell::GetVisible(other_cell_id) (0x0052dc10), copy_view. Success ⇒ DrawPortalPolyInternal(poly, pass==1) + DrawCells; failure ⇒ nothing. DrawPortalPolyInternal (0x0059bc90) with defaults maxZ1=7/maxZ2=6 (pc:1105964-65) = alpha-0 DEPTHTEST_ALWAYS z-write draw: far-plane punch (open) or own-depth seal (exit portals, DrawCells pc:432785-432786). Drawing BSP is stripped to the portal skeleton at load (RemoveNonPortalNodes, 0x0053a040, in InitLoad pc:318775).
- acdreamEvidence: Portal polys flow into the flattened mesh and draw unconditionally (ObjectMeshManager.cs:1040-1058); portal polygons are otherwise used only as visibility-clip math (PortalVisibilityBuilder.cs:189-194, PortalProjection.cs:54); the z-seal hook DrawExitPortalMasks is invoked but never implemented (RetailPViewRenderer.cs:325-341, nullable at :497/:534/:558, no production assignment repo-wide); no z-punch exists at all.
- portShape: Keep the flatten (it is retail-faithful) but EXCLUDE nothing statically. Add a per-building per-frame portal pass before the shell entity draws: walk the dat DrawingBSPNode.Portals skeleton (or just the building's CBldPortal list — the BSP walk only orders submissions), and per portal poly run the ported ConstructView gate (eye-side vs portal_side, clip against the current view region, cell-beyond loaded/visible, view push). On success render the portal poly as an invisible far-plane depth punch (depth ALWAYS, depth-write on, color mask off) and credit the through-portal flood; never draw fills as visible geometry from this pass. Implement DrawExitPortalMasks as the own-depth z-seal for portals with other_cell_id==0xFFFF. This unifies doors, windows, and the hall stair apertures under one mechanism, matching the
e223325mandate.
[HIGH] solid-surface-skip-missing (adjusted) — Building/cell mesh draw renders untextured (solid) surface batches that retail skips (skipNoTexture rule)
- correctedClaim: CONFIRMED MECHANISM, CONDITIONAL BLAST RADIUS: Retail's D3DPolyRender::DrawMesh (0x0059d4a0, gate asm 0x0059d4e0-0x0059d50c) skips any surface batch with (CSurface.type & 6)==0 (no BASE1_IMAGE/BASE1_CLIPMAP, acclient.h:5820) when skipNoTexture (global 0x00820e30, data default 1) is on AND the draw is a building shell (ObjBuildingOrBuildingPart=1, set only around DrawBuilding's second/mesh pass at 0x0059f322) or an EnvCell built-mesh draw (DrawEnvCell pushes 1 at 0x0059f20d); plain objects keep their solid batches (2-arg overload 0x0059d790 pushes 0). acdream has no such skip — ObjectMeshManager.cs:1075-1088 and :1429-1442 promote solid surfaces to visible 32x32 solid-color textures and draw them unconditionally. The divergence is REAL (severity: high as a mechanism, but its user-visible instances are unpinned). CORRECTIONS: (1) attributing the visible body of #113 stair ramps / door slabs to this rule is unverified — the
e223325audit never recorded whether those portal-fill polys are solid vs textured; settle that dat question first; (2) because ALL those fills are conditionally-drawn portal polys, porting the static skip ALONE would reproduce thee46d3d9door-vanish regression if the fills are solid — sequence it with the conditional portal-poly pass, never as a standalone "drop from flatten"; (3) the port predicate must be absence-of-(IMAGE|CLIPMAP), not acdream's isSolid (which conflates NoPos stippling); (4) the skip is only proven for the built-mesh path — DrawEnvCell's raw-poly fallback (pc:427922) was not verified to skip, though acdream's pipeline corresponds to the built-mesh path anyway. - verifier notes: RETAIL SIDE — verified at raw-assembly level via Ghidra (not BN pseudo-C), and it checks out exactly as claimed:
- The gate in D3DPolyRender::DrawMesh (4-arg overload, 0x0059d4a0). Disassembly at 0x0059d4e0–0x0059d50c: MOV EAX,[0x00820e30] (skipNoTexture); JZ 0x0059d516 (skip disabled → draw); MOV EAX,[EBP+ESI*4]; TEST byte ptr [EAX+0x58],0x6; JNZ 0x0059d516 (surface textured → draw); MOV EAX,[0x008ed3c4] (ObjBuildingOrBuildingPart); JNZ 0x0059d58e (→ loop increment = SKIP the batch); MOV AL,[ESP+0x2c] (4th arg, cell flag); JNZ 0x0059d58e (SKIP); else MOV [0x00820e30],0x1 and fall into the draw path at 0x0059d516. So: skip iff skipNoTexture!=0 AND (CSurface.type & 6)==0 AND (building-flag OR cell-flag). The odd-looking re-assert
skipNoTexture=1in the draw-anyway branch is real in the asm but semantically inert (the global was already nonzero to reach it). Both decompilers show the same structure — no invented branch here. - type&6 semantics: acclient.h:5820 SurfaceType enum — BASE1_SOLID=0x1, BASE1_IMAGE=0x2, BASE1_CLIPMAP=0x4. (type&6)==0 = neither image nor clipmap = solid-color-only. (CSurface is only forward-declared in acclient.h:342; offset 0x58=type comes from BN's typed pseudo-C at pc:426064-426074 — acceptable since the bit semantics are anchored by the enum.)
- Default-on: data section at 0x00820e30
int32_t skipNoTexture = 0x1(pc:1105971 block, dumped). - Cell path: DrawEnvCell (0x0059f170) calls the 4-arg DrawMesh with PUSH 0x1 at 0x0059f20d → CALL 0x0059d4a0 at 0x0059f212 (pc:427905 region) — but only on the use_built_mesh path; the use_built_mesh==0 fallback submits raw polys with planeMask=0xffffffff through the poly list (pc:427922) and I did NOT verify an equivalent skip there. Built-mesh is the production path and is what acdream's batching corresponds to, so the comparison stands.
- Building path: RenderDeviceD3D::DrawBuilding (0x0059f2a0, pc:427956 region) sets ObjBuildingOrBuildingPart=1 at 0x0059f322 BETWEEN CPhysicsPart::Draw(part,1) and Draw(part,0), clearing at 0x0059f33b. Important nuance the claim glossed: the first Draw (flag still 0) is the PORTALS-ONLY pass (DrawMeshInternal 0x0059f360 → BSPTREE::build_draw_portals_only(drawing_bsp,1/2) at 0x0059f3ca/0x0059f3d7); the second Draw (flag=1) is the constructed-mesh shell draw — that's where the skip bites.
- Plain objects draw solid batches: the 2-arg DrawMesh overload (0x0059d790) forwards with PUSH 0x0 as the 4th arg (0x0059d7aa), and Ghidra xrefs show the ONLY callers of 0x0059d4a0 are the 2-arg overload (0x0059d7b2) and DrawEnvCell (0x0059f212). Outside DrawBuilding's second pass the global is 0, so ordinary GfxObjs keep their solid batches. Confirmed.
ACDREAM SIDE — confirmed: src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1075-1088 (PrepareGfxObjMeshData, used for building shells) and :1429-1442 (PrepareCellStructMeshData, the EnvCell path — call sites :519/:607/:1350) both compute isSolid and promote the surface to a visible 32×32 solid-color texture via TextureHelpers.CreateSolidColorTexture (src/AcDream.Core/Rendering/Wb/TextureHelpers.cs:6), batching it for unconditional draw. Repo-wide grep for Base1Solid/CreateSolidColorTexture/skipNoTexture confirms NO building- or cell-scoped untextured-skip exists anywhere: WbDrawDispatcher.cs uses IsBuildingShell only at :610/:639 (unrelated scoping), EnvCellRenderer.cs has no surface-type logic, and TextureCache.cs:394-400 does the same promote-to-solid-texture on its path. Anchor correction: IsBuildingShell is GameWindow.cs:5265 (claim said 5266), defined at WorldEntity.cs:65, tagged at LandblockLoader.cs:86.
WHY ADJUSTED, NOT CONFIRMED — two material corrections:
(a) The blast-radius attribution to #113 phantom stairs and door-aperture slabs is UNPROVEN. The e223325 audit (tests/.../Issue113DoorVanishDiagnosticTests.cs) proved every orphan poly is a DrawingBSPNode.Portals PortalRef but recorded NO surface-type data (grep for Solid/SurfaceType/Stippling in that test: zero hits; no session dump captures it either). Whether those fill quads are solid or textured is exactly the claim's own open question 1 — so "likely visible body of #113" is a hypothesis, not a finding. If the fills are textured, this rule has nothing to do with #113 and the blast radius shrinks to whatever genuinely-solid shell/cell surfaces exist (unquantified).
(b) The proposed "simpler" port shape (drop solid batches from the flatten for shell/cell meshes) is NOT safe in isolation — it can REPRODUCE the e46d3d9 door regression. Per e223325, ALL the orphan fills (doors AND stairs) are portal polys drawn conditionally by retail's portal pass (build_draw_portals_only, the flag==0 first pass of DrawBuilding). If the fills turn out solid, retail's shell-mesh pass skips them ALL and their retail visibility ("doors usually visible") comes solely from the conditional portal pass — so shipping the static skip before the portal pass exists would vanish doors again. The skip must land WITH or AFTER the conditional portal-poly pass.
(c) Predicate precision: a faithful port must skip on ABSENCE of (BASE1_IMAGE|BASE1_CLIPMAP) ((type&6)==0), not on presence of Base1Solid, and must not reuse acdream's isSolid (which conflates NoPos stippling at :1075/:1429 — a NoPos-stippled poly with a textured surface would be wrongly skipped).
- blastRadius: Any building-shell or EnvCell polygon whose surface is solid-color renders in acdream as an opaque colored quad where retail shows nothing — the likely visible body of the #113 phantom stair ramps and of door-aperture slabs (the fills users perceived as 'doors'); also the reason a faithful portal pass ALONE won't fix #113: even with the gate ported, the flattened solid fills would still draw.
- retailEvidence: D3DPolyRender::DrawMesh inner loop (Ghidra 0x0059d4a0 at 0x0059d4f1, pc:426064-426074): when skipNoTexture != 0 (data-section default 1, pc:1105971 @0x00820e30) a surface batch with (CSurface.type & 6)==0 (no Base1Image, no Base1ClipMap) is skipped if RenderDeviceD3D::ObjBuildingOrBuildingPart==1 (set during the shell pass, pc:427956) or the cell-draw flag is passed (DrawEnvCell passes 1, pc:427905); plain objects still draw solid batches.
- acdreamEvidence: PrepareGfxObjMeshData and PrepareCellStructMeshData promote solid surfaces into visible 32×32 solid-color textures and batch them for unconditional draw (ObjectMeshManager.cs:1075-1088 and :1430); no building/cell-scoped surface-type skip exists anywhere in the dispatcher or EnvCellRenderer.
- portShape: Tag batches at decode with 'is building shell / is cell geometry' (already known: IsBuildingShell GameWindow.cs:5266, cell path PrepareCellStructMeshData) and 'surface is untextured' (neither Image nor ClipMap), then skip those batches at draw — or simpler, drop them from the flatten for building-shell and cell-struct meshes only (equivalent because the retail rule is per-surface, static, and default-on). Must NOT apply to ordinary objects (retail draws their solid batches). Verify first against the dat which fills are solid vs textured (open question 1).
[MEDIUM] degrade-lod-scoped-to-humanoids (confirmed) — Distance LOD (deg_level) exists only as a static Degrades[0] swap for 34-part humanoids; retail degrades every non-player part per frame
- correctedClaim: Claim stands as written, with one citation label tightened: the call sites 0x0051827a/0x005182b3 are inside the two CPartArray::UpdateViewerDistance overloads (0x00518260/0x00518290) — the per-part loop owned by CPhysicsObj — rather than functions literally named on CPhysicsObj. Substance unchanged. One strengthening addendum: retail's LoadGfxObjArray excludes the base GfxObj from the render array entirely when a degrade table exists, and INVALID_DID degrade entries become null slots that suppress drawing beyond a distance band (DrawBuilding gates the whole shell draw on gfxobj[deg_level] != NULL) — so the divergence also covers "objects/buildings that should vanish or simplify at distance never do," not just perf and close-detail silhouettes.
- verifier notes: RETAIL side re-derived entirely from Ghidra decompiles (port 8081), not BN pseudo-C. (1) CPhysicsPart::UpdateViewerDistance @ 0x0050E030: computes distance from Render::viewer_pos to the part's scaled sort_center every call, stores it in CYpt; if this->degrades != NULL and the owning physobj's IID != player_iid, calls GfxObjDegradeInfo::get_degrade(CYpt / gfxobj_scale.z, °_level, °_mode); otherwise pins deg_level=0/deg_mode=1; then runs calc_draw_frame only if gfxobj[deg_level] != NULL. Exactly as claimed, including the player_iid exemption. (2) Caller xrefs match the claim verbatim: 0x0051827a/0x005182b3 (inside the two CPartArray::UpdateViewerDistance overloads @ 0x00518260/0x00518290 — a per-part loop; the claim labeled these "CPhysicsObj update paths," a trivial naming imprecision since CPartArray is CPhysicsObj's part container), 0x005a0712/0x005a073c inside RenderDeviceD3D::UpdateObjCell @ 0x005a0690, 0x00510c53 in UpdateViewerDistanceRecursive, and 0x0059f2bc inside RenderDeviceD3D::DrawBuilding @ 0x0059f2a0. (3) UpdateObjCell is the per-frame proof: called from DrawObjCellForDummies and DrawBlock (the render walk), it iterates the cell's shadow_object_list calling CPhysicsObj::UpdateViewerDistance on EVERY object, with a near/far split at MAX_CELL_2D_DEGRADE_DISTANCE (near = exact per-object distance, far = shared cell distance via the float/Vector3 fast-path overload — a useful port detail). (4) DrawBuilding re-picks the shell LOD first thing each draw and gates the ENTIRE building draw on gfxobj[deg_level] != 0 — stronger than claimed: buildings can legitimately skip drawing at far LOD. (5) CPhysicsPart::Draw @ 0x0050D7A0 indexes gfxobj[deg_level] with clamp-to-0 when degrades==NULL || num_degrades <= deg_level, exactly as claimed. (6) LoadGfxObjArray @ 0x0050DCF0 preloads one CGfxObj* per degrade entry; notably, when a degrade table exists the BASE GfxObj id is NOT in the render array at all (it is loaded only to discover the degrade DID), and INVALID_DID entries become null slots = draw-nothing distance bands — retail objects can intentionally vanish past a distance, which acdream can never reproduce. ACDREAM side verified by reading production code: GfxObjDegradeResolver.cs:105-143 always returns Degrades[0] (explicit ack at :56-60 "always returns slot 0... far-distance LOD is a future concern"); the resolver's sole production call site is GameWindow.cs:2610 inside the
_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)gate at :2605 (predicate :302-312: 34 parts + >=8 null-sentinel 0x010001EC slots in 17-33; RetailCloseDegrades is default-on per RuntimeOptions.cs:76-79, so humanoids DO get the swap in production); WbDrawDispatcher.cs:987-993 trusts MeshRefs and never re-picks ("We trust MeshRefs as the source of truth here"); ObjectMeshManager.cs carries DIDDegrade only as write-only metadata (:700, :726, :1274, :1896) — the only reads anywhere in src/ are point-sprite-mode checks (Degrades[0].DegradeMode == 2) in ParticleEmitterRenderer.cs:81-86 and ParticleRenderer.cs:400-414, plus a [scenery-z] diagnostic dump at GameWindow.cs:5424-5459 — none constitute a distance-LOD mechanism. Door claim verified via commite223325message: doors (setup 0x020019FF) don't take the humanoid swap and render base ids; base + every degrade variant have full BSP coverage, so nothing in #113/#114 depends on this item, matching the port-shape claim. The divergence is REAL (no behaviorally-equivalent mechanism exists anywhere in acdream), the severity (medium) and blast radius (perf; conditional wrong silhouettes for non-humanoid degrade-table models whose base id is the low-detail variant; latent correctness gap for the holistic port since DrawBuilding re-picks shell LOD per frame) are accurate and if anything slightly understated: retail's null-degrade-slot "vanish at distance" behavior and the building-draw-skip gate are correctness consequences acdream's always-draw-base approach cannot reproduce. Port shape is sound, with two refinements from the decompiles: retail picks deg_level per PART (each part has its own pos/sort_center), not per entity — per-entity is an acceptable first approximation but should be stated as such; and retail's far-cell fast path (MAX_CELL_2D_DEGRADE_DISTANCE shared-distance overload) is the retail-faithful way to keep the per-frame cost bounded. - blastRadius: No named open issue; consequences are (a) perf — full-detail meshes drawn at all distances for every static/building/creature, (b) wrong silhouettes for any non-humanoid model whose base GfxObj id is the LOW-detail variant (the #47 bulky-mesh class — fixed for humanoids only), (c) a latent correctness gap for the holistic port since retail's DrawBuilding explicitly re-picks the shell LOD per frame.
- retailEvidence: CPhysicsPart::UpdateViewerDistance (Ghidra 0x0050E030): per-frame GfxObjDegradeInfo::get_degrade(distance/scale) for every part with a degrade table EXCEPT the local player (player_iid check ⇒ deg_level=0); called from CPhysicsObj update paths (0x0051827a/0x005182b3), cell-object update (0x005a0712/0x005a073c), recursive parts (0x00510c53) and DrawBuilding itself (0x0059f2bc); CPhysicsPart::Draw indexes gfxobj[deg_level] with clamp (0x0050D7A0); LoadGfxObjArray (0x0050DCF0) preloads the array.
- acdreamEvidence: GfxObjDegradeResolver always returns Degrades[0] (GfxObjDegradeResolver.cs:105-143, ack at :56-60); sole production call gated by IsIssue47HumanoidSetup (GameWindow.cs:2605-2624, predicate :302-313); dispatcher trusts MeshRefs and never re-picks (WbDrawDispatcher.cs:987-992); doors/statics/buildings render base ids (confirmed for the door setup 0x020019FF in
e223325). - portShape: Move degrade resolution from spawn-time id swap to a per-entity-per-frame deg_level pick: preload all degrade slots' meshes into the global buffer at registration (retail LoadGfxObjArray analog), compute viewer distance per entity in the dispatcher walk (it already has camera position for the sort), select the MeshRefs slot via a ported get_degrade. Local player pinned to slot 0. Can ship after the critical items; nothing in #113/#114 depends on it (
e223325: door degrade chains have full BSP coverage).
[MEDIUM] no-per-view-entity-pass (adjusted) — Entities are culled once against the camera frustum + membership slot; retail re-culls each object's sphere against EVERY active portal view
- correctedClaim: CONFIRMED CORE, with corrections: acdream culls entities once against the camera frustum (AABB) plus cell-granular gates (visibleCellIds membership + U.4 clip-slot routing), and on the indoor cell-object path (RetailPViewRenderer.DrawCellObjectLists → DrawEntityBucket with neverCullLandblockId=player LB) even the frustum cull is bypassed — indoor cell objects draw with NO geometric cull. Retail re-culls each object part's drawing_sphere against EVERY active portal view: PView::DrawCells sets Render::PortalList = &pview->outside_view for the landscape pass and = cell->portal_view[num_view-1] per drawn cell before DrawObjCellForDummies → DrawPartCell (per-cell shadow_part_list); RenderDeviceD3D::DrawMesh (0x005a0860) loops view_count views (filtered by building_view), set_view + viewconeCheck(drawing_sphere) per view, returning OUTSIDE_VIEWCONE_ODS undrawn when all views fail; DrawMeshInternal (0x0059f360) dedups multi-view/multi-cell submission via CPhysicsPart::GetDrawnThisFrame (player parts exempt). BLAST-RADIUS CORRECTION: only the over-draw direction (membership cell visible, sphere outside every view) is explained by this divergence; the under-draw direction (object poking through an aperture from a non-visible membership cell) requires retail's per-cell shadow-part registration (multi-cell membership), which the sphere-vs-plane port alone does not provide — that half belongs to the A6.P4 per-cell shadow architecture gap. Citation fixes: the acdream "unclipped" comment is RetailPViewRenderer.cs:370-372/:441-450, not :395-398; GetDrawnThisFrame is 0x0050d4d0/0x0052c0c0 (0x0059f360 is DrawMeshInternal, its caller).
- verifier notes: RETAIL side — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
-
RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860): CONFIRMED verbatim. When
Render::PortalList == NULLit does ONEviewconeCheck(gfxobj->drawing_sphere); whenPortalList != NULLit loopsPortalList->view_countviews, per view callingRender::set_view(&PortalList->view, i)thenRender::viewconeCheck(param_1->drawing_sphere). A view returning OUTSIDE increments a counter; if ALL views are OUTSIDE the function returns OUTSIDE_VIEWCONE_ODS without drawing. One nuance the claim omitted: a(building_view == -1 || building_view == i)filter restricts the loop to a single view index during building draws. -
Render::viewconeCheck (Ghidra 0x0054c250): CONFIRMED — transforms the sphere to viewer space, tests against
viewer_world_space.CY(near plane) thenportal_npntsplanes atportal_vertex, returning OUTSIDE on any plane with dist < -radius. Render::set_view (Ghidra 0x0054d0e0) is what loadsportal_npnts/portal_vertex(plus xmin/xmax/ymin/ymax scissor bounds) from the active view'sview_poly— so viewconeCheck genuinely tests the per-view portal plane set. -
PView::DrawCells (Ghidra 0x005a4840): CONFIRMED with one precision fix — for landscape it sets
Render::PortalList = &this->outside_view(the portal_view_type at PView offset 0, acclient.h:45936; BN pc:432718 prints the rawthispointer because of the zero offset), thenLScape::draw. The final loop (pc:432877 region, addr 0x005a4b07-0x005a4b0d) setsRender::PortalList = cell->portal_view.data[cell->num_view-1]per drawn cell and calls vtable DrawObjCellForDummies(cell). Traced onward: DrawObjCellForDummies (0x005a0760) → DrawObjCell (0x005a1a40) → DrawPartCell (0x005a07a0) iterates the cell'sshadow_part_listcalling CShadowPart::draw — so cell objects ARE drawn under the per-cell accumulated view list and re-culled per view in DrawMesh. -
Dedup: CONFIRMED but mis-cited — 0x0059f360 is RenderDeviceD3D::DrawMeshInternal, which CONTAINS the dedup (
CPhysicsPart::GetDrawnThisFrameearly-return +SetDrawnThisFrame, player parts exempt via IsPartOfPlayerObj); GetDrawnThisFrame itself is at 0x0050d4d0/0x0052c0c0. Multi-view and multi-cell submission draws once. Substance correct.
ACDREAM side — read from production code:
-
WbDrawDispatcher.cs:657-666: CONFIRMED — single camera-frustum AABB cull per entity (
FrustumCuller.IsAabbVisible), bypassed for animated entities and forentry.LandblockId == neverCullLandblockId. Cell-granular gates:EntityPassesVisibleCellGate(:1816-1835, ParentCellId ∈ visibleCellIds) and U.4 clip-slot routingSetClipRouting/ResolveEntitySlot/ResolveSlotForFrame(:324-331, :425-489). No sphere-vs-view-plane test exists anywhere on the entity path (grep for viewcone in src/ hits only comments describing retail). -
RetailPViewRenderer.cs: citation drift — the "deliberately unclipped" rationale is at :370-372 and :441-447 (inside UseIndoorMembershipOnlyRouting, which calls
_entities.ClearClipRouting()at :449), not :395-398 (:396-398 is the clip-disable loop). Substance correct: DrawCellObjectLists (:401-426) draws each visible cell's entity bucket with clip routing cleared. -
STRENGTHENING finding the claim missed: DrawEntityBucket (:460-477) passes
neverCullLandblockId: ctx.PlayerLandblockIdwhile tagging the bucket withlbId = ctx.PlayerLandblockId ?? 0u— so indoor cell objects on the player's landblock skip even the camera-frustum AABB cull. The indoor cell-object path currently has NO geometric cull at all, only the cell-granular membership gate. -
MISATTRIBUTION in the blast radius: the "vice versa" direction (object poking through an aperture from a non-visible membership cell still draws in retail) is produced by retail's per-cell shadow-PART registration (DrawPartCell iterates each cell's shadow_part_list; an object straddling a portal has parts registered in BOTH cells, deduped by GetDrawnThisFrame), NOT by the per-view sphere re-cull. acdream's single ParentCellId membership cannot replicate it, and the proposed sphere-vs-plane port alone will not close that half — it belongs to the per-cell shadow architecture gap (A6.P4 debt). The first direction (membership cell visible but sphere outside every view → drawn in acdream, culled in retail) is fully explained by the claimed mechanism and is real.
Port shape: plausible as stated (CPU sphere-vs-plane loop against per-cell accumulated view planes, draw once on first passing view); ClipFrameAssembly.CellIdToViewSlices (RetailPViewRenderer.cs:428-437) confirms per-cell view-plane data exists. Note retail additionally carries per-view 2D scissor bounds (set_view xmin/xmax/ymin/ymax) which the sphere-cull port does not replicate; and the acdream comment's rationale that retail does not hard-clip meshes per view is consistent with the decompile (DrawMeshInternal's BoundingType param is unused for built meshes). Severity medium is fair: visible artifact class at portal boundaries, contributes to #109, entity-side half of one-gate discipline.
- blastRadius: Object pop/visibility mismatches at portal boundaries seen through doorways (an object whose membership cell is visible but which lies outside every portal view draws in acdream, not in retail; and vice versa for objects poking through an aperture from a non-visible cell). Contributes to the residual far-door artifact class (#109) and is the entity-side half of the one-gate discipline; particles share the 'no view gate' hole (particles-through-walls is the named bug; ParticleRenderer is outside this area's scope).
- retailEvidence: RenderDeviceD3D::DrawMesh portal-list branch (Ghidra 0x005a0860): when Render::PortalList != NULL it loops view_count views, set_view + viewconeCheck(drawing_sphere) per view (viewconeCheck Ghidra 0x0054c250 tests the sphere against the active view's portal planes); PView::DrawCells sets PortalList=pview for landscape (pc:432718) and per-cell views for cell objects (pc:432877); per-frame dedup via GetDrawnThisFrame keeps multi-view submission single-draw (0x0059f360).
- acdreamEvidence: WbDrawDispatcher: single camera-frustum AABB cull per entity (WbDrawDispatcher.cs:657-665) plus membership-cell slot routing/culling (:314-488); no per-view sphere re-cull; cell objects deliberately drawn unclipped (RetailPViewRenderer.cs:395-398 comment).
- portShape: Per drawn cell, test each member entity's bounding sphere against that cell's accumulated view planes (the data already exists in ClipFrameAssembler slices) before adding its instances — a CPU sphere-vs-plane loop in the partition step, not a GPU change. Retail-faithful = test against the view's plane set, draw once on first pass.
[MEDIUM] stippling-semantics-divergence (confirmed) — WB's NoPos/NoNeg = 'don't draw this side' interpretation is not visible in retail's ConstructMesh; retail treats stippling>0 as a batch FLAG and sides via sides_type
- verifier notes: RETAIL re-derived from Ghidra decompiles (BN pseudo-C used only for line citations, cross-checked): (1) CPolygon::UnPack (Ghidra 0x00538650, pc:322296) reads pos_uv_indices only when (stippling & 4)==0 and neg_uv_indices only when sides_type==2 && (stippling & 8)==0; pos_surface/neg_surface always read; sides_type==1 aliases neg_surface=pos_surface and neg_uv=pos_uv. NoPos/NoNeg are dat-stream UV-PRESENCE flags, not draw suppressors. (2) D3DPolyRender::ConstructMesh inner (Ghidra 0x0059dfa0; emit/count loop pc:426842-426909) counts and emits triangles purely from sides_type (0=pos once, 1=pos doubled same surface, 2=pos+neg into separate surface batches) with an explicit 'uv_indices==null -> uv index 0' fallback in the emit loop — so NoPos/NoNeg sides ARE still emitted; the ONLY stippling use is batchFlagByte[pos_surface] |= (stippling>0) (Ghidra 0x0059e1c3 = pc:426868), confirming 'stippling>0 is a batch flag'. (3) D3DPolyRender::SetSurface (Ghidra 0x0059d650, pc:426157-426189) maps bit0->POSITIVE-side stippled bool, bit1->NEGATIVE-side stippled bool, as claimed. (4) ConstructMesh is the real runtime path: CGfxObj::InitLoad (Ghidra 0x005346b0, pc:318781) builds constructed_mesh from the FULL polygon array via the wrapper at 0x0059ea90 (pc:427540-427543) and sets use_built_mesh=1; an EnvCell-side caller at 0x0052d87a (pc:311085) feeds cell geometry through the same inner function; the immediate fallback DrawPolyInternal (Ghidra 0x0059d7c0) also has zero bit-4/8 checks (bit0->stippled, cull from sides_type: ==1 -> CULLMODE_NONE else CULLMODE_CW). (5) Exhaustive grep of all 15 'stippling' hits in the 1.4M-line pseudo-C found no other draw-path consumer (remainder: ctor/Pack/UnPack init+presence bits, terrain CLandBlockStruct::ConstructUVs writing 3 at pc:316764, which is terrain-only and outside this claim). ACDREAM verified at src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs: :1046 skips pos side on NoPos; :1052-1058 gates neg side on Negative||Both||(!NoNeg && SidesType==Clockwise); cell path :1394-1404 suppresses both sides on NoPos/NoNeg with an in-comment retail assertion ('still suppress hidden portal/cap faces') that has no retail citation and is contradicted by the UnPack+ConstructMesh evidence above. Enum values pinned by reflection on the referenced package (Chorizite.DatReaderWriter 2.1.7, net48 dll): CullMode Landblock=0/None=1/Clockwise=2/CounterClockwise=3 maps onto retail sides_type 0/1/2, so the sides_type gates themselves (None->double, Clockwise->neg) are retail-correct; StipplingType NoPos=4/NoNeg=8 match retail bit positions. Behavioral-equivalence check I ran to try to refute: the GfxObj path's missing sides_type==1 pos-side doubling (cell path doubles at :1399-1401, GfxObj path does not) is MASKED at draw time — batches carry CullMode=poly.SidesType (:1251,:1596) and WbDrawDispatcher.ApplyCullMode disables face culling for CullMode.None (WbDrawDispatcher.cs:1495-1517, :1504-1505) — so one emission shows both faces; correctly NOT part of the divergence. What survives as REAL divergence: (a) NoPos suppression of the pos side (:1046, :1394/:1397) — retail emits it with UV fallback 0; (b) NoNeg suppression of the neg side on sides_type==2 (:1054, :1395/:1402) — retail emits it; (c) BONUS beyond the claim: stipple bits 0/1 misread as 'neg side exists' (:1052-1053) adds phantom negative-side faces on sides_type!=2 polys carrying those bits, and the per-side stippled-translucency render flag retail derives from bits 0/1 is entirely unimplemented (acdream instead maps NoPos->solid-color texture at :1075/:1429 — also not retail). Honest residual (data-level, could not settle from code): whether NoPos/NoNeg-flagged sides in the actual Holtburg model set are ever VISIBLE in retail (e.g. if they correlate with the
e223325conditional portal polys, suppression could be coincidentally invisible — but the conditional mechanism there is BSP-portal-based, not stippling-based, so WB's stippling interpretation is still mechanically wrong). The claim's proposed port shape — a per-surface triangle-count conformance diff against retail's ConstructMesh arithmetic on a fixed model set, landed BEFORE any BSP/portal poly filter so the two filters aren't conflated — is exactly the right instrument and I endorse it. Severity medium is appropriate: quiet correctness class, no named issue, but a hard precondition for pixel-wise validation of the flatten. - blastRadius: Potential missing or extra faces on models/cells whose polys carry NoPos/NoNeg or stipple bits — a quiet correctness class with no named issue yet; matters for the port because the flatten must reproduce retail's triangle set exactly before the portal pass can be validated pixel-wise.
- retailEvidence: Inner ConstructMesh (Ghidra 0x0059dfa0, emit loop pc:426842-426909) counts/emits triangles purely from sides_type (1/2 double the count) with no stippling-based skip; stippling>0 only ORs a per-surface batch flag bit (Ghidra 0x0059dfa0 counting loop; pc:426868-426871); at draw, D3DPolyRender::SetSurface (0x0059d650, pc:426157-426189) maps stippling bit0/bit1 to a per-side 'stippled' render flag.
- acdreamEvidence: PrepareGfxObjMeshData skips the positive side on NoPos (ObjectMeshManager.cs:1046) and gates the negative side on Negative/Both/NoNeg+CullMode (:1052-1058); PrepareCellStructMeshData asserts in-comment that 'DAT-side NoPos/NoNeg flags still suppress hidden portal/cap faces' (:1388-1402) — an interpretation, not a retail citation.
- portShape: Conformance test: for a fixed model set, reproduce retail's ConstructMesh triangle count per surface (the counting loop is simple arithmetic over sides_type/num_pts) and diff against acdream's flatten; then either drop or keep the NoPos/NoNeg suppression to match. Do this BEFORE landing the solid-skip so the two filters aren't conflated.
[LOW] no-frame-dedup (adjusted) — No per-frame part dedup (retail GetDrawnThisFrame / frame-stamp)
- correctedClaim: Retail dedups part draws with a per-pass frame stamp (CPhysicsPart::Draw @ 0x0050D7A0 early-outs when m_current_render_frame_num == m_nFrameStamp; DrawMeshInternal @ 0x0059f360 Get/SetDrawnThisFrame for non-player parts; stamp bumped at Flip pc:428642 and again mid-frame in DrawCells pc:432722, so the window is landscape-pass / cell-pass, with player parts exempt and the building portal-draw mode bypassing it). acdream has no per-part dedup — but the no-double-draw invariant is upheld not by the MDI design alone: each production frame issues multiple WbDrawDispatcher.Draw calls (one per visible cell, RetailPViewRenderer.cs:408-425/:470, plus Outdoor/LiveDynamic buckets), and the invariant rests on InteriorEntityPartition's one-bucket-per-entity assignment (InteriorEntityPartition.cs:29-73), the distinct flood list (PortalVisibilityBuilder.cs:170-172), and one explicit hand-rolled cross-pass guard (liveDynamicsDrawn, GameWindow.cs:7813) that is itself a bucket-granularity dedup. Severity low / no user-visible effect today stands. If divergence 4 (per-view or per-cell-shadow object lists, where one entity appears in multiple cells' lists) is ported, the frame-stamp dedup becomes load-bearing and must be ported with retail's exact shape: first-wins within the cell pass, player-part exemption, portal-draw-mode bypass.
- verifier notes: RETAIL (all re-derived from Ghidra decompiles via MCP, not BN pseudo-C): (1) CPhysicsPart::Draw @ 0x0050D7A0 — gate confirmed:
(draw_state & 1)==0 && (param_1 != 0 || this->m_current_render_frame_num != RenderDevice::render_device->m_nFrameStamp); the frame-stamp early-out is BYPASSED when the portal-mode arg (param_1) is nonzero. (2) RenderDeviceD3D::DrawMeshInternal @ 0x0059f360 — confirmed: for non-portal draws (!param_2) with s_current_physics_part set AND NOT IsPartOfPlayerObj, GetDrawnThisFrame ⇒ early return INSIDE_VIEWCONE_ODS, else SetDrawnThisFrame (pc:427965-427981, matches claimed pc:427972-427980 region). Helpers verified: GetDrawnThisFrame @ 0x0050d4d0 returnsm_current_render_frame_num == render_device->m_nFrameStamp; SetDrawnThisFrame @ 0x0050d4f0 assigns it — same field CPhysicsPart::Draw checks, one mechanism at two levels. (3) PView::DrawCells —m_nFrameStamp += 1at exactly pc:432722 (addr 005a4886), after LScape::draw + FlushAlphaList, before the cell_draw_list loop. Grep of all m_nFrameStamp writes shows the only OTHER bump is RenderDeviceD3D::Flip (pc:428642, addr 0059fec8): the stamp advances twice per presented frame, so retail's dedup window is PER-PASS (within the landscape pass; within the cell pass) — a part may legally draw once in each. Two retail nuances the claim omitted: player parts are exempt (IsPartOfPlayerObj), and the building portal-draw mode (param_2/build_draw_portals_only path) bypasses the dedup entirely.
ACDREAM: WbDrawDispatcher.Draw is a self-contained build+upload+MDI cycle: WalkEntitiesInto clears scratch and emits each (entity, MeshRefIndex, landblockId) tuple exactly once (WbDrawDispatcher.cs:587-698 — each landblock entry's Entities iterated once; an entity lives in one entry); each instance written once (1182-1189); one indirect command per group (1206-1229); MDI over those commands (1465-1493). The cited :1206+/:1478-1490 are the build/dispatch, not themselves the dedup property, but support it. HOWEVER the "once per frame by construction of the MDI design" rationale is incomplete: production frames issue MULTIPLE Draw calls — one per visible cell in the PView flood (RetailPViewRenderer.cs:408-425 → DrawEntityBucket :460-477 → _entities.Draw :470), plus Outdoor and LiveDynamic buckets (GameWindow.cs:7813-7823 / InteriorRenderer.cs:141-163), or the legacy global pass only when clipRoot is null (GameWindow.cs:7825-7831). The once-per-frame invariant actually rests on three things: (a) InteriorEntityPartition assigns each entity to exactly ONE bucket via its single ParentCellId (InteriorEntityPartition.cs:29-73); (b) OrderedVisibleCells is distinct by construction (PortalVisibilityBuilder.cs:170-172), so each cell bucket draws once; (c) an EXPLICIT hand-rolled guard if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0) (GameWindow.cs:7813) prevents the outdoor-portal pass and the main pass from both drawing LiveDynamic — i.e., acdream already needed one cross-pass dedup, implemented at bucket granularity. EntitySet enum has only All (WbDrawDispatcher.cs:75-80), so no overlapping set-partition passes exist today.
JUDGMENT: the divergence is REAL (retail has a per-part frame-stamp dedup; acdream has none) and the low severity / no-blast-radius-today assessment holds — acdream's bucket disjointness + the :7813 guard make per-instance double-submission impossible in current production paths. Adjusted because (i) the "by construction, no dedup needed" rationale understates how the invariant is maintained (it is partition-disjointness + one explicit guard across multiple per-frame Draw calls, not the MDI design alone — and the guard proves the double-draw class already bites this architecture), and (ii) the port shape needs retail's three qualifiers: first-wins applies within a pass (stamp bumps at Flip pc:428642 AND mid-frame in DrawCells pc:432722), player parts are exempt, and the building portal-draw mode bypasses the dedup.
- blastRadius: None today — the MDI design submits each instance once per frame by construction; becomes relevant only if the per-view entity pass (divergence 4) is ported naively as draw-per-view.
- retailEvidence: CPhysicsPart::Draw frame-stamp check (Ghidra 0x0050D7A0); DrawMeshInternal GetDrawnThisFrame/SetDrawnThisFrame for non-player parts (Ghidra 0x0059f360, pc:427972-427980); DrawCells bumps m_nFrameStamp before through-portal drawing (pc:432722).
- acdreamEvidence: WbDrawDispatcher submits one instance per (entity, batch) per frame (WbDrawDispatcher.cs:1206+, :1478-1490); no dedup needed.
- portShape: Only port if divergence 4 is implemented as multi-view submission: first-passing-view-wins, exactly retail's dedup.
OPEN QUESTIONS
- Surface class of each portal fill poly in the dat (the deciding fact for divergence 2): are the door-fill and stair-ramp quads' PosSurface/NegSurface untextured Base1Solid (retail-skipped) while window fills are textured (retail-drawn)? The Issue113DoorVanishDiagnosticTests dump (tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs) records geometry but not Surface.Type/Stippling per orphan — extend it with PosSurface/NegSurface/Stippling + Surface.Type before implementing the solid-skip.
- Does retail's inner ConstructMesh (Ghidra 0x0059dfa0) really emit BOTH sides per sides_type with zero stippling-based suppression? The decompile is register-garbled in places; a cdb capture of a known model's constructed-mesh triangle count (or a careful Ghidra re-read of the emit loop) would settle WB's NoPos/NoNeg interpretation (divergence 5).
- DrawPortalPolyInternal's ±12.0 vertex filter (Ghidra 0x0059bc90: skips the draw when ALL vertices sit at x or y = ±12.0) — presumed to exclude synthetic full-cell-boundary portals from depth punching; not traced to a dat-side producer.
- maxZ1/maxZ2 are config-backed globals (defaults 7/6 at 0x00820e14/0x00820e18, registry strings nearby suggest they may be tunable); the z-punch/z-seal semantics asserted here assume shipped defaults — a retail-client registry dump would confirm no override ships.
- Frame-level ordering of DrawBuilding relative to terrain and the alpha flush (when exactly the punch can erase already-drawn terrain depth, and whether the shell mesh always repairs over-punch) belongs to the Area-2/3 frame-orchestration map; within DrawBuilding the order portal-pass-then-shell is proven (pc:427955-427957) but the surrounding LScape::draw sequence was not traced here.
- Child traversal order (pos vs neg first) in BSPNODE::build_draw_portals_only is unrecoverable from the padding-mangled decompile (Ghidra 0x0053c100); irrelevant for depth-only submissions but should be pinned via the binary's field offsets if the port reproduces the walk literally.
- Defaults of Render::m_RenderPrefs.MultiPassAlpha and D3DPolyRender::s_AlphaDelayMask (the alpha/translucent/clipmap deferred-list routing in 0x0059d4a0) — they shape translucent-fill parity but were not chased to their config initializers.
- Where exactly the #113 phantom staircase was observed from (indoor vs outdoor viewpoint) — determines whether the user-visible fix lands with divergence 1 (portal pass + flood discipline), divergence 2 (solid-skip), or both; the dat facts are consistent with both contributing.