acdream/docs/research/2026-06-11-holistic-map/wf1-statics-dynamics.md
Erik 5e2f99d08e docs: Phase A comparison + Phase B port plan (holistic building-render investigation)
Deliverable 1: docs/research/2026-06-11-building-render-acdream-vs-retail-
comparison.md - the acdream-vs-retail architecture comparison synthesized
from two ultracode mapping fan-outs (11/12 areas, ~90 agents, every retail
claim Ghidra/pc-cited, every acdream claim file:line, 40/76 divergences
adversarially verified so far; raw per-area evidence committed under
docs/research/2026-06-11-holistic-map/).

Headline findings: (1) retail flattens GfxObjs/cells at load exactly like
us (ConstructMesh + RemoveNonPortalNodes) - the MDI pipeline survives;
(2) the phantom/door mechanism is the skipNoTexture draw-time surface gate
(dat-confirmed); (3) retail never geometrically clips world geometry -
aperture exactness is a DEPTH discipline (punch maxZ1 / seal maxZ2 / gated
clear + far-to-near whole-mesh draws) - reframes #114; (4) flood admission
is already faithful, the trigger/depth/multi-view/cone-culling layers are
missing; (5) #115 root cause verified (boom damping severed from the
published collided viewer); collision A6.P4 design verified with
corrections (signed other_portal_id >= 0 gate).

Deliverable 2: docs/plans/2026-06-11-building-render-port-plan.md - the
phased port plan (BR-1 surface gate, BR-2 depth punch/seal, BR-3 delete
the shell chop, BR-4 draw-driven floods, BR-5 viewconeCheck, BR-6 one
gate, BR-7 collision A6.P4, BR-8 camera/lighting/LOD) with per-phase
acceptance criteria, bug closures, keep-list, and a playable-after-every-
phase migration order. AWAITING USER APPROVAL - no implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00

166 lines
63 KiB
Markdown

# AREA 4 — Statics and dynamic objects in cells (incl. particles-through-walls)
## RETAIL
RETAIL'S OBJECT-IN-CELL DRAW MODEL — one registration mechanism, one draw pass, for everything.
1) REGISTRATION: every drawable thing in the world — an EnvCell's baked statics, a door, an NPC, the player, even a particle emitter — is a CPhysicsObj whose visual pieces are CPhysicsParts. When an object lands in cells, CPhysicsObj::add_shadows_to_cells (0x514ae0, pc:282819) creates one CShadowObj per overlapped cell (the collision-side record) AND calls CPartArray::AddPartsShadow (0x517e40, pc:285933) per overlapped cell, which registers each part into that cell's render-side list: CPartCell::shadow_part_list (a DArray<CShadowPart*>, acclient.h:30889-30894; CObjCell derives from CPartCell, acclient.h:30915). So a part that straddles two rooms is in BOTH rooms' lists. EnvCell statics are not special: CEnvCell::init_static_objects (0x52c350, pc:309690) makeObject()s each static_object_id and add_obj_to_cell()s it — after that it is an ordinary CPhysicsObj drawn through the same shadow parts (CEnvCell fields static_object_ids/static_objects at acclient.h:32080-32083 are only the spawn manifest, not a draw list).
2) THE DRAW PASS: PView::DrawCells (0x5a4840, pc:432709) runs three loops over cell_draw_list (the portal flood), all far-to-near (reverse list). Loop 1 (only when outside_view has slots): sets Render::PortalList=&outside_view, LScape::draw (landscape; per-landblock DrawBlock 0x5a17c0 gates each land cell on its cached cell->IsInView() BoundingType, then vtbl+0x54 DrawLandCell terrain + vtbl+0x58 DrawSortCell 0x59f140 = DrawBuilding for the cell's building + DrawObjCell for its objects), then per indoor cell draws the exit-portal polys (other_cell_id==-1) per view slot via DrawPortalPolyInternal (pc:432786). Loop 2: per cell, per portal_view slot, CEnvCell::setup_view (0x52c430) installs that slot's view planes then vtbl+0x5c RenderDeviceD3D::DrawEnvCell (0x59f170, pc:427885) draws the cell STRUCTURE — note the per-frame dedup at entry (GetDrawnThisFrame) and the use_built_mesh fast path (whole prebuilt vertex-buffer mesh; the per-poly planeMask=0xffffffff software-clip submit at pc:427922 is only the non-built fallback; CEnvCell::UnPack constructs the mesh at runtime, ConstructMesh call at pc:311085/0x52d87a, mirroring CGfxObj::InitLoad 0x5346b0 pc:318778-318784). Loop 3 — THE OBJECT PASS (pc:432883-432886, 0x5a4af3-0x5a4b0d): per cell, set Render::PortalList = the cell's top portal_view (the accumulated set of view cones looking into this cell), then vtbl+0x64 DrawObjCellForDummies(cell) (0x5a0760, pc:429177) = UpdateObjCell (0x5a0690, refreshes viewer distances/LOD off shadow_object_list) + CShadowPart::insertion_sort (depth-sort the cell's parts) + DrawObjCell (0x5a1a40) → DrawPartCell (0x5a07a0, pc:429198) which iterates shadow_part_list calling CShadowPart::draw (0x6b50d0, pc:701104) → CPhysicsPart::Draw(part, 0).
3) PER-PART VISIBILITY GATES (CPhysicsPart::Draw 0x50d7a0, pc:274964): (a) skip if draw_state&1 (the NoDraw flag); (b) skip if m_current_render_frame_num == device frame stamp — the dedup that makes multi-cell registration draw-once; then (c) RenderDeviceD3D::DrawMesh (0x5a0860, pc:429245): with PortalList set it LOOPS EVERY VIEW SLOT, Render::set_view(view, slot) + Render::viewconeCheck(gfxobj->drawing_sphere) (0x54c250, pc:342860 — the part's bounding sphere against the slot's portal-cone planes, returning OUTSIDE/PARTIALLY/ENTIRELY); any non-OUTSIDE slot draws via DrawMeshInternal (0x59f360, pc:427965) which has a second per-frame dedup (GetDrawnThisFrame, player parts exempt — the player redraws per slot) and then draws the WHOLE constructed mesh through D3DPolyRender::DrawMesh (0x59d4a0, pc:426048 — pure HW subset draws + alpha-list deferral, NO software poly clip, NO user clip planes). CONFIRMED: meshes are sphere-vs-cone checked per portal-view slot and never hard poly-clipped.
4) BUILDINGS (CBuildingObj is itself a CPhysicsObj): DrawBuilding (0x59f2a0, pc:427938) publishes building->portals into outdoor_pview->outdoor_portal_list, then CPhysicsPart::Draw(part, 1) → DrawMeshInternal's building branch runs BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (…, 2) (pc:427993-427994) — the DrawingBSP is stripped to portal nodes at load (RemoveNonPortalNodes, pc:318775) so this walk visits only BSPPORTAL nodes (0x53d870, pc:326881), each calling vtbl DrawPortal(portalPoly, 1, pass) → PView::DrawPortal (0x5a5ab0, pc:433895) → ConstructView(CBldPortal…) (0x5a59a0, pc:433827): viewer-side test against the portal poly's plane (portal_side sign), GetClip against the current view, target cell loaded (CEnvCell::GetVisible). Pass-1 success → DrawPortalPolyInternal(poly, true) (0x59bc90, pc:424490 — a DEPTHTEST_ALWAYS screen fan, z forced ~far; a z-mask, and it bumps portalsDrawnCount which triggers the z-clear in DrawCells). Pass-2 success → recurse views into the interior + DrawCells(this,1) draws the interior cells through the aperture. FAILURE → the portal poly is NOT submitted at all (the param_3==3 "fill on failure" branch in DrawPortal has no reachable caller — only portal_draw_portals_only calls it, with pass∈{1,2}). So building portal polys (door/window fillers, the meeting-hall stair apertures) are drawn CONDITIONALLY, exactly per the fresh e223325 finding, and the building's main constructed mesh (node.Polygons only) draws unconditionally afterward via CPhysicsPart::Draw(part, 0).
5) PARTICLES: an emitter is a CPhysicsObj with state bit PARTICLE_EMITTER_PS=0x1000 (acclient.h:2829); its per-particle quads are the CPhysicsParts of its own part_array. add_shadows_to_cells routes such objects to CPhysicsObj::add_particle_shadow_to_cell (0x514a70, pc:282799, branch at pc:282875): ONE CShadowObj + AddPartsShadow into exactly ONE cell — this->cell. Therefore particle quads DRAW only inside loop-3 of the cell they live in, under that cell's portal_view, sphere-vs-cone checked per slot like any mesh. An emitter inside an unflooded building simply never reaches the renderer — its cell is not in cell_draw_list. A second, UPDATE-side gate: ParticleEmitter::UpdateParticles (0x51d180, pc:291770) calls CPhysicsObj::ShouldDrawParticles(physobj, degrade_distance) (0x50fe60, pc:277959): true iff examination-object OR (viewer distance CYpt <= degrade_distance AND cell != null AND cell->IsInView()). CLandCell::IsInView (0x532cb0, pc:316897) returns the in_view BoundingType cached by the landscape pass; CEnvCell::IsInView is an ICF-folded constant `return 1` (vftable slot pc:1019224 → 0x5269f0, pc:303646) — indoor emitters always pass the cell check and are distance-gated only. Failing → CPhysicsObj::SetNoDraw(1) (degraded_out) → parts skip via draw_state&1; far emitters stop simulating AND drawing.
6) LIST ROLES (Q4): the DRAW iterates CPartCell::shadow_part_list (CShadowPart→CPhysicsPart, acclient.h:30889-30894). The collision side iterates CObjCell::shadow_object_list (CShadowObj→CPhysicsObj, acclient.h:30923-30924). CObjCell::object_list (acclient.h:30919-30920) is the membership/enumeration list (get_object etc.). The only draw-path use of shadow_object_list is UpdateObjCell's viewer-distance/LOD refresh (0x5a0690, pc:429129).
## ACDREAM
ACDREAM'S EQUIVALENT — per-frame entity partition + single-cell buckets + a separate global particle system.
1) PARTITION (per frame, not a persistent registration): RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109) builds the flood (PortalVisibilityBuilder.Build + R-A2 per-building MergeNearbyBuildingFloods :60-61,:115-145), then InteriorEntityPartition.Partition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) walks all landblock entities and puts EACH entity in exactly ONE bucket keyed by its single ParentCellId: indoor cell id and cell ∈ flood → ByCell[cell]; indoor cell id but cell ∉ flood → DROPPED (:67-68); outdoor/no cell → Outdoor; ServerGuid!=0 with null ParentCellId → LiveDynamic.
2) DRAW ORDER (RetailPViewRenderer.DrawInside): landscape per outside-view slice (:93 → GameWindow.DrawRetailPViewLandscapeSlice, src/AcDream.App/Rendering/GameWindow.cs:9465-9551 — terrain + the WHOLE partition.Outdoor bucket per slice :9503-9512, scissored to the slice NDC AABB), exit-portal masks (:95), shells via EnvCellRenderer (:104-105, src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:1-19 — shell geometry only, gl_ClipDistance-cropped only for outdoor roots per #114 scope :96-105), then DrawCellObjectLists (:401-426): reverse OrderedVisibleCells (far→near :408), skip cells without buckets (:414), clear entity clip routing (UseIndoorMembershipOnlyRouting :439-450 — deliberate: entities are never gl_ClipDistance-clipped, comment cites retail's viewcone-not-clip behavior), draw the bucket through WbDrawDispatcher.Draw with visibleCellIds={cell} (:460-477), then invoke DrawCellParticles per clip slice (:423-424; GetCellSlicesOrNoClip falls back to a FULL-SCREEN NoClipSlice when the cell has no slot :428-437).
3) ENTITY VISIBILITY GATES (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs WalkEntitiesInto :576-700): landblock AABB vs camera frustum (:593-595), EntityPassesVisibleCellGate = ParentCellId ∈ visibleCellIds when a set is passed, null-ParentCellId fails a cell filter (:1816-1835), per-entity AABB vs the one CAMERA frustum (:662-666). There is no per-portal-view-slot cone test and no multi-cell registration; LiveDynamic is drawn unclipped only for OUTDOOR roots (GameWindow.cs:7716-7724) — indoor roots never draw it.
4) PARTICLES: emitters live in a global AcDream.Core.Vfx.ParticleSystem keyed by AttachedObjectId = owning entity's guid/id (GameWindow.ParticleEntityKey :5072-5073); the renderer (src/AcDream.App/Rendering/ParticleRenderer.cs:119-171) draws camera-billboard instanced quads, depth test ON / depth write OFF (:141-143), no clip distances. Scene-pass gating is set-membership built per frame (sets cleared at GameWindow.cs:7521-7522): (a) per outside slice, emitters attached to partition.Outdoor entities (:9514-9530, scissor = slice NDC AABB); (b) per flooded cell, emitters attached to that cell's bucket (DrawRetailPViewCellParticles :9553-9580, scissor = slice AABB or full screen). Emitters with AttachedObjectId==0 never draw under a pview root (filters require !=0 at :9528 and :9575); when clipRoot is null (pre-spawn/legacy fallback, clipRoot=viewerRoot??_outdoorNode :7497) ALL Scene emitters draw globally unfiltered (:7860-7868). There is NO emitter-own-cell membership, NO sphere-vs-portal-cone test, and NO distance/degrade gate (no degrade logic in src/AcDream.Core/Vfx/ParticleSystem.cs). The WB-extracted Wb/ParticleEmitterRenderer.cs + Wb/ActiveParticleEmitter.cs are referenced nowhere in production — dead code.
5) BUILDINGS: exterior building GfxObjs are Outdoor-bucket entities drawn whole by WbDrawDispatcher; post-revert 124c6cb ALL dictionary polys draw unconditionally, including the baked door/window portal quads and the meeting-hall stair-aperture polys — there is no equivalent of the conditional DrawPortal/ConstructView portal-poly submission.
## DIVERGENCES
### [CRITICAL] building-portal-polys-unconditional (UNVERIFIED (verifier hit token limit)) — Building portal polys drawn unconditionally instead of retail's ConstructView-conditional submission
- blastRadius: #113 phantom staircase (stair-aperture portal polys always drawn), the door mystery (e46d3d9 filter removed doors because door quads are portal polys too), and outside-looking-in door/window appearance generally. Same mechanism, opposite signs, exactly as the e223325 fresh finding predicted.
- retailEvidence: Building DrawingBSP is stripped to portal nodes at load (RemoveNonPortalNodes, pc:318775); main mesh = ConstructMesh of node.Polygons only (CGfxObj::InitLoad 0x5346b0, pc:318778-318784). Portal polys submit ONLY via DrawBuilding (0x59f2a0, pc:427938) → CPhysicsPart::Draw(part,1) → build_draw_portals_only passes 1+2 (pc:427993-427994) → BSPPORTAL::portal_draw_portals_only (0x53d870, pc:326881) → PView::DrawPortal (0x5a5ab0, pc:433895) → ConstructView(CBldPortal) (0x5a59a0, pc:433827): viewer-side + clip-nonempty + target-cell-loaded. Success pass-1 → z-mask fan DrawPortalPolyInternal(poly,true) (0x59bc90, pc:424490); success pass-2 → recurse + DrawCells(interiors); failure → poly NOT drawn (param_3==3 fill branch unreachable — only callers pass 1/2).
- acdreamEvidence: All GfxObj dictionary polys drawn unconditionally post-revert (commit 124c6cb un-applied the e46d3d9 static filter); buildings are Outdoor-bucket entities drawn whole via WbDrawDispatcher (GameWindow.cs:9503-9512). No ConstructView-conditional path exists; R-A2 MergeNearbyBuildingFloods (RetailPViewRenderer.cs:115-145) decides which interiors flood but never gates the portal POLYS.
- portShape: Split building meshes at upload: unconditional slice (node.Polygons) + one indexed sub-range per portal poly (node.Portals PolyId/PortalIndex, helper already landed in e223325). Per frame per visible building, run the acdream ConstructViewBuilding result through the same decision retail makes: portal view constructed → draw the interior through it + (optionally) the z-mask; not constructed → submit nothing for that portal poly. The door weenie keeps drawing via its cell bucket.
### [CRITICAL] particles-not-cell-resident (adjusted) — Particle draw gated by owner-entity bucket + 2D scissor instead of emitter-own-cell residency + per-slot cone check
- correctedClaim: Particle draw is gated by OWNER-entity bucket membership + 2D scissor AABB (full-screen fallback for slot-less cells) + depth test, instead of retail's emitter-own-cell residency + per-view-slot sphere-vs-portal-plane cone check + degrade_distance freeze/hide. Retail (all Ghidra-verified): a particle emitter is a CPhysicsObj with PARTICLE_EMITTER_PS=0x1000 (acclient.h:2829) routed by add_shadows_to_cells (0x514ae0, state&0x1000 branch) into add_particle_shadow_to_cell (0x514a70) which registers parts into exactly ONE cell (this->cell); quads draw only in PView::DrawCells' per-cell object pass (0x5a4840, pc:432877-432886) under that cell's portal_view via DrawMesh's per-slot set_view+viewconeCheck plane test (0x5a0860/0x54c250); UpdateParticles (0x51d180) degrades far/out-of-view emitters via ShouldDrawParticles (0x50fe60: CYpt<=degrade_distance && cell->IsInView(), vptr+0x68; CLandCell cached in_view 0x532cb0, CEnvCell constant-true 0x5269f0 at vftable 0x7c8d00) -> SetNoDraw. acdream: global ParticleSystem keyed by owner id (GameWindow.cs:5072-5073), draw filters are owner-membership in per-cell/outdoor buckets built from the OWNER entity's ParentCellId (InteriorEntityPartition.cs:55-73) + slice NDC-AABB scissor with full-screen NoClipSlice fallback (RetailPViewRenderer.cs:22-23,428-437; GameWindow.cs:9707-9724) + depth test (ParticleRenderer.cs:141-143); emitters have no cell (VfxModel.cs:177-193), no cone test, no degrade (none in src/AcDream.Core/Vfx; Tick unconditional GameWindow.cs:7346). CORRECTIONS to the original: (1) on clipRoot-null frames only the clipAssembly==null sub-case draws all Scene emitters unfiltered (GameWindow.cs:7860-7867); with a clip assembly the outdoor pass is filtered and explicitly ADMITS AttachedObjectId==0 emitters (:7856) — so world-positioned emitters are dropped only under an indoor pview root (clipRoot non-null; filters :9528/:9575), not everywhere. (2) Far flames are not eternal — streaming Near->Far demotion removes them with their owner entity; the divergence is the absence of any retail degrade_distance gate within the near tier, where flames simulate and draw at any distance. Explains #114 re-test item 2 (flames visible through walls precisely where occluder statics are not drawn, since depth is then the only gate and the global/full-screen paths bypass the bucket filter); severity critical stands.
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (port 8081), not BN pseudo-C; every branch-sensitive claim CONFIRMED: (1) PARTICLE_EMITTER_PS = 0x1000 read directly from acclient.h:2829 region. (2) Ghidra 0x514ae0 CPhysicsObj::add_shadows_to_cells: `if ((this->state & 0x1000) == 0)` -> multi-cell CELLARRAY registration, ELSE add_particle_shadow_to_cell — particle emitters bypass multi-cell registration (BN pc:282815-282880 matches Ghidra; branch is real). (3) Ghidra 0x514a70 add_particle_shadow_to_cell: num_shadow_objects=1, registers into exactly this->cell via CObjCell::add_shadow_object (0x52b280, appends to cell shadow_object_list) + CPartArray::AddPartsShadow(part_array, this->cell, 1) (0x517e40: per-part cell->add_part virtual, null clip planes in single-cell case). shadow_part_list is a real cell field (acclient.h:30893). (4) Draw: PView::DrawCells = 0x5a4840 (pc:432709-432889); its final per-cell loop sets Render::PortalList = cell->portal_view[num_view-1] then RenderDevice->DrawObjCellForDummies(cell) (pc:432877-432886); DrawObjCellForDummies 0x5a0760 sorts the cell's shadow parts and dispatches the cell-part draw; DrawMesh 0x5a0860 (Ghidra) with PortalList non-null loops PortalList->view_count slots doing Render::set_view(slot) + Render::viewconeCheck(gfxobj->drawing_sphere), skipping OUTSIDE slots; viewconeCheck 0x54c250 is a sphere-vs-plane-set test (viewer CY plane + the current view's portal polygon planes loaded by set_view) — plane cone, NOT an AABB. (5) Degrade: Ghidra 0x50fe60 ShouldDrawParticles = m_bExaminationObject bypass, else (CYpt <= degrade_distance && cell != null && virtualcall(cell vptr+0x68) != 0). The +0x68 slot is IsInView: CEnvCell main vftable base = 0x7c8c98 (pc:1019182), +0x68 = 0x7c8d00 holding the ICF-folded constant-return function 0x5269f0 (pc:1019224 region) -> CEnvCell::IsInView constant-true; CLandCell::IsInView 0x532cb0 returns cached this->in_view; BN's own decompile names the call cell->vtable->IsInView() (pc:277990). Ghidra 0x51d180 ParticleEmitter::UpdateParticles: ShouldDrawParticles(physobj, this->degrade_distance) fail -> SetNoDraw(physobj,1) + degraded_out=1 (particles killed/frozen); recover -> SetNoDraw(0). All retail claims check out.
ACDREAM SIDE — all cited lines verified in src/AcDream.App/Rendering/: ParticleEntityKey = ServerGuid-or-Id OWNER key (GameWindow.cs:5072-5073); outdoor-bucket filter excluding AttachedObjectId==0 (GameWindow.cs:9514-9530, inside DrawRetailPViewLandscapeSlice :9465); per-cell-bucket filter + slice scissor excluding ==0 (DrawRetailPViewCellParticles GameWindow.cs:9553-9580); buckets keyed by the OWNER entity's ParentCellId gated on visibleCells (InteriorEntityPartition.cs:17,35-48,55-73) — the emitter itself has NO cell field (VfxModel.cs:177-193) and ParticleSystem.cs has zero degrade/distance/NoDraw logic (grep over src/AcDream.Core/Vfx: no matches; Tick unconditional at GameWindow.cs:7346); full-screen NoClipSlice fallback for slot-less cells (RetailPViewRenderer.cs:22-23 NdcAabb=(-1,-1,1,1), :428-437, invoked per cell at :423-424) feeding BeginDoorwayScissor's NDC->pixel rect (GameWindow.cs:9707-9724); depth-test-on/depth-write-off (ParticleRenderer.cs:141-143); DisableClipDistances() precedes every particle draw (GameWindow.cs:9518, 9568, 7839-7840) so portal clip planes are explicitly OFF for particles. #114 re-test item 2 text matches verbatim (docs/ISSUES.md:3800-3804: 'particle pass is not gated by the same flood').
TWO OVERSTATEMENTS in the original claim (the reason for 'adjusted'): (a) 'clipRoot-null frames draw all Scene emitters unfiltered (GameWindow.cs:7846-7868)' is wrong for half the cited range — when clipRoot==null AND clipAssembly!=null the draw IS filtered, and that filter explicitly ADMITS AttachedObjectId==0 emitters (:7856); only the clipAssembly==null sub-case (:7860-7867) is unfiltered-global. Consequently 'AttachedObjectId==0 emitters never draw under any pview root' is correct only as scoped (clipRoot non-null frames — verified: the 7846 block is skipped and both pview filters :9528/:9575 exclude ==0), NOT a general drop — they do draw outdoors. (b) 'far flames simulate + draw forever' — no degrade gate exists, but emitter lifetime is bounded by streaming: Near->Far demotion removes the owner entity and its VFX (C.1.5b GpuWorldState OnRemove hooks), so the real divergence is 'no retail degrade_distance equivalent at any range inside the near tier (N1=4 LBs)', not literally forever. CORE DIVERGENCE IS REAL AND NOT HANDLED ELSEWHERE: nothing in the codebase resolves an emitter to its own cell, no plane-cone test exists on any particle path (scissor AABB is overbroad + degenerates to full-screen for slot-less cells; depth test only occludes where occluder meshes are actually drawn — exactly the failure mode of #114 item 2 where non-visible cells' statics are not drawn), and the one-drawing-discipline invariant is broken for the particle pass. Severity 'critical' stands. Port shape as proposed is consistent with the verified retail mechanism (emitter-cell residency at spawn/anchor-update, draw inside the per-cell object pass keyed by EMITTER cell, sphere-vs-slice-plane-set test reusing slice planes, degrade_distance freeze+hide).
- blastRadius: particles-through-walls (#114 re-test item 2: candle flames inside other buildings visible while their statics' meshes are not drawn); also missing distance degrade (far flames simulate + draw forever) and dropped world-positioned emitters (AttachedObjectId==0 Scene emitters never draw under any pview root).
- retailEvidence: Emitter = CPhysicsObj with PARTICLE_EMITTER_PS (acclient.h:2829); registered into exactly ONE cell's shadow_part_list (add_particle_shadow_to_cell 0x514a70 pc:282799, branch pc:282875); quads draw only in that cell's loop-3 object pass (pc:432883-432886) under the cell's portal_view with per-slot viewconeCheck on the part sphere (DrawMesh 0x5a0860 pc:429245; viewconeCheck 0x54c250 pc:342860). Update/emission gated by ShouldDrawParticles (0x50fe60 pc:277959): CYpt <= degrade_distance AND cell->IsInView() (CLandCell cached in_view 0x532cb0 pc:316897; CEnvCell constant-true ICF 0x5269f0 pc:303646, vftable slot pc:1019224); fail → SetNoDraw degrade-out (ParticleEmitter::UpdateParticles 0x51d180 pc:291770).
- acdreamEvidence: Global ParticleSystem keyed by AttachedObjectId = OWNER entity id (GameWindow.cs:5072-5073); draw filters are set-membership of the owner in the per-cell bucket or Outdoor bucket (GameWindow.cs:9519-9530, 9553-9580) + scissor rectangle of the slice NDC AABB with FULL-SCREEN fallback for slot-less cells (RetailPViewRenderer.cs:428-437) + depth test (ParticleRenderer.cs:141-143). No emitter-own-cell, no cone test, no degrade (ParticleSystem.cs has none). clipRoot-null frames draw all Scene emitters unfiltered (GameWindow.cs:7846-7868); AttachedObjectId==0 emitters excluded by every pview filter (:9528, :9575).
- portShape: Give each emitter a cell residency (resolve the emitter anchor's cell on spawn/anchor-update — retail uses the particle physobj's single cell) and draw Scene particles inside DrawCellObjectLists' per-cell pass keyed by EMITTER cell, with a sphere-vs-portal-view-cone test per slice (reuse the slice's plane set, not its AABB). Add a degrade_distance gate that freezes + hides far emitters (retail SetNoDraw semantics). Route AttachedObjectId==0 emitters through their position's cell.
### [HIGH] single-cell-buckets-vs-shadow-parts (confirmed) — One ParentCellId bucket per entity vs retail's register-in-every-overlapped-cell with draw-once dedup
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (127.0.0.1:8081), not BN pseudo-C:
1. Multi-cell registration confirmed. CPhysicsObj::calc_cross_cells (Ghidra 0x515280) builds a CELLARRAY via CObjCell::find_cell_list over the object's cylspheres (or sorting sphere) — i.e. every cell the object's volume overlaps — then calls remove_shadows_from_cells + add_shadows_to_cells. CPhysicsObj::add_shadows_to_cells (Ghidra 0x514ae0, pc:282819) loops the CELLARRAY twice: first loop creates one CShadowObj per cell (set_physobj + cell_id), second loop calls CObjCell::add_shadow_object(cell, shadowObj, num_cells) AND CPartArray::AddPartsShadow(part_array, cell, num_shadow_objects) for EVERY non-null cell. Children recurse (0x514bf7/0x514c06). Callers: calc_cross_cells, calc_cross_cells_static, SetPositionInternal (Ghidra xrefs). Claim verified exactly.
2. Per-cell registration feeds the draw. CPartArray::AddPartsShadow (Ghidra 0x517e40) registers every CPhysicsPart with the cell via a CObjCell virtual — and notably passes the cell's clip_planes when num_shadows > 1, i.e. multi-cell-straddling parts carry per-cell clip info. Draw side: RenderDeviceD3D::DrawObjCell (Ghidra 0x5a1a40) → DrawPartCell (0x5a07a0) iterates the cell's shadow_part_list and calls CShadowPart::draw (0x6b50d0) → CPhysicsPart::Draw(part, 0).
3. Draw-once dedup confirmed with one mechanism refinement. CPhysicsPart::Draw (Ghidra 0x50d7a0, pc:274964-274971) skips when m_current_render_frame_num == render_device->m_nFrameStamp (active because the shadow path passes param=0). DrawMeshInternal (Ghidra 0x59f360) does GetDrawnThisFrame/SetDrawnThisFrame with an explicit IsPartOfPlayerObj exemption. Refinement: both gates read the SAME field — GetDrawnThisFrame/SetDrawnThisFrame (0x50d4d0/0x50d4f0, pc:274730-274743) compare/assign m_current_render_frame_num vs m_nFrameStamp, and the stamp is only ever SET in DrawMeshInternal for non-player parts (CPhysicsPart::Draw reads but never writes it). Net behavior is exactly as claimed: a part registered in N cells draws once per frame; player parts are never stamped so the player draws in every cell slot.
ACDREAM SIDE — all cited lines verified against the working tree:
4. WorldEntity.ParentCellId is a single uint? (src/AcDream.Core/World/WorldEntity.cs:46); every write site assigns one resolved membership cell (GameWindow.cs:4915, 6798, 8426, 8756, 11506). No overlapped-cell set exists on the entity.
5. InteriorEntityPartition.Partition buckets each entity under that single cell (src/AcDream.App/Rendering/InteriorEntityPartition.cs:37-44) and AddByCellOrOutdoor silently drops the entity when its cell is not in the flood (:67-68 `if (!visibleCells.Contains(cellId)) return;`) — added to NO list, not even LiveDynamic (LiveDynamic only takes ServerGuid!=0 entities with ParentCellId==null, :37-40).
6. The draw consumes only the single-cell buckets: RetailPViewRenderer.DrawCellObjectLists iterates visible cells back-to-front and draws partition.ByCell[cellId] only (src/AcDream.App/Rendering/RetailPViewRenderer.cs:408-421); WbDrawDispatcher.EntityPassesVisibleCellGate is `visibleCellIds.Contains(entity.ParentCellId.Value)` (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1816-1835) — exact match of the claimed lines.
7. No compensating path exists. The outdoor-root fallback partition at GameWindow.cs:7732-7734 passes _outdoorRootNoCells (cleared = EMPTY set), so indoor-celled entities are dropped there too; the DrawPortal look-in path (RetailPViewRenderer.cs:193, 208) gates on the same single ParentCellId ∈ drawableCells. The only unfiltered draws (visibleCellIds:null at GameWindow.cs:7720-7722, 7816-7822) are restricted to the LiveDynamic bucket, which by construction excludes indoor-celled entities.
8. Port-shape premise verified: the physics side already computes the overlapped-cell set — CellTransit.FindCellSet returns the dedup'd CellArray ported from retail find_cell_list (src/AcDream.Core/Physics/CellTransit.cs:505-554), so bucketing into every overlapped visible cell + a per-frame drawn-stamp (player exempt) is a faithful and locally-scoped port.
JUDGMENT: the divergence is real, not behaviorally equivalent, and not handled elsewhere. Retail's contract is "an object exists in every cell it overlaps; dedup happens at draw time" — acdream's is "an object exists in exactly one cell; if that cell isn't flooded the object doesn't render." Any boundary-straddling object (door mid-swing, doorway NPC, multi-cell furniture) pops out whenever its membership cell leaves the flood while an overlapped cell stays visible. The #109 attribution is correctly hedged as a candidate contributor (a far door blinking as its single home cell enters/leaves the flood is consistent, but flood-set instability could also contribute — not proven here). Severity high (visible artifact class, not a one-drawing-discipline break) is appropriate. One strengthening detail found during verification: AddPartsShadow passes the cell's clip_planes to parts when the object spans >1 cell, so retail's multi-cell registration also supports per-cell portal-clipped drawing of straddlers — a faithful port should keep that in mind when wiring the dedup stamp.
- blastRadius: Pop-in/pop-out of boundary-straddling objects: a door mid-swing, an NPC standing in a doorway, multi-cell furniture — visible whenever the membership cell leaves the flood while an overlapped cell is still visible. Candidate contributor to #109 (far-door oscillation: the door object blinking as its single cell enters/leaves the flood) and to doorway-NPC flicker.
- retailEvidence: add_shadows_to_cells creates one CShadowObj AND registers parts per EVERY cell in the object's CELLARRAY (0x514ae0, pc:282819, AddPartsShadow per cell pc:282866); draw-once enforced at part level by the frame-stamp check in CPhysicsPart::Draw (0x50d7a0, pc:274964) + GetDrawnThisFrame in DrawMeshInternal (0x59f360, player parts exempt so the player draws in every slot).
- acdreamEvidence: InteriorEntityPartition.AddByCellOrOutdoor buckets each entity under its single ParentCellId and silently drops it when that cell is not in the flood (InteriorEntityPartition.cs:37-48, :67-68). WbDrawDispatcher's cell gate is ParentCellId ∈ visibleCellIds (WbDrawDispatcher.cs:1816-1835).
- portShape: Replace the single ParentCellId key with the entity's overlapped-cell set (the physics side already computes a CELLARRAY in CellTransit/Transition — AREA 6 territory); bucket the entity into every overlapped visible cell and dedup at draw with a per-frame drawn-stamp on the entity (player exempt). Small change to InteriorEntityPartition + a drawn-set in DrawCellObjectLists.
### [HIGH] shells-drawn-whole-in-retail-production (confirmed) — Retail production draws EnvCell shells as whole prebuilt meshes (use_built_mesh), not per-poly portal-clipped — acdream's #114 'pixel-exact indoor crop' target may be chasing the fallback path
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN pseudo-C) at every load-bearing branch:
1. RenderDeviceD3D::DrawEnvCell (Ghidra decompile 0x59f170) is exactly as claimed: entry dedup `if (!CEnvCell::GetDrawnThisFrame(cell)) { SetDrawnThisFrame; ... }`, then `if (cell->use_built_mesh != 0) { D3DPolyRender::SetStaticLightingVertexColors(constructed_mesh, &pos); D3DPolyRender::DrawMesh(num_surfaces, surfaces, constructed_mesh, true); return; }`. The per-poly submit (`PolyNext->planeMask = -1` i.e. 0xffffffff, then polyListFinishInternal) is ONLY the else branch. pc:427922 indeed falls inside this function's else-branch poly loop (function spans pc:427885-427930; `use_built_mesh` gate at pc:427902/0059f1e9, planeMask line at 0059f24d). GetDrawnThisFrame is a true frame-epoch dedup: `m_current_render_frame_num == render_device->m_nFrameStamp` (0x52c0d2, pc:309546).
2. Production cells take the built-mesh branch: Ghidra decompile of CEnvCell::UnPack tail (function 0x52d470) shows `calc_clip_planes(this); if (DBCache::IsRunTime()) { this->use_built_mesh = 1; if (this->constructed_mesh == NULL) { if (D3DPolyRender::ConstructMesh(num_surfaces, surfaces, &structure->vertex_array, structure->num_polygons, structure->polygons, 3.0, true, &this->constructed_mesh)) return 1; } this->use_built_mesh = 0; }` — ConstructMesh call at 0x52d87a as cited. So use_built_mesh=0 only when ConstructMesh fails (or non-runtime tooling). The CGfxObj::InitLoad mirror exists at pc:318778/318784, and CEnvCell genuinely owns the fields (acclient.h:32086-32087 `MeshBuffer *constructed_mesh; int use_built_mesh;` inside CEnvCell struct 3405; the CGfxObj pair is acclient.h:31720-31721).
3. The caller IS the production path: PView::DrawCells (0x5a4840, pc:432709) loop 2 walks cell_draw_list and per view calls CEnvCell::setup_view then `render_device->vtable->DrawEnvCell(cell)` (pc:432852-432853, addr 005a4ab9-4abe); vtable slot at 007e555c resolves to RenderDeviceD3D::DrawEnvCell (pc:1037070).
4. Adversarial kill-shot attempt FAILED (which is what confirms the claim): I checked whether the per-view setup installs hardware clip planes that would also crop the built mesh. Render::set_view (Ghidra 0x54d0e0) only writes software-clipper globals (portal poly vertex pointer/count, inmask, 2D xmin/xmax/ymin/ymax) — no D3D state. D3DPolyRender::DrawMesh (Ghidra 0x59d4a0) never reads those globals — it sets FVF and per-subset either defers to the alpha list or calls RenderMeshSubset; no clip planes, no scissor. Grep for SetClipPlane/CLIPPLANE across the 1.4M-line pseudo-C hits only the D3D render-state NAME string table (pc:1044713+), no code. Structural second proof: the GetDrawnThisFrame dedup means a multi-view cell draws on the FIRST setup_view only — if per-view geometric clipping were load-bearing for the built-mesh branch, multi-view cells would render wrongly in retail; the dedup is only coherent if the whole-mesh draw is view-clip-independent (visibility = cell selection into cell_draw_list + portal z-masks + depth).
ACDREAM SIDE — read the production code, not docs: RetailPViewRenderer.cs:345-399 DrawEnvCellShells enables GL_CLIP_DISTANCE0..N around the shell pass only when clipShells (lines 378-380/396-398) and applies per-slice gl_ClipDistance crops via UseShellClipRouting (:390). Production call sites: DrawInside passes `clipShells: ctx.RootCell.IsOutdoorNode` (:104-105) with the #114 scope comment at :96-103; DrawPortal passes `clipShells: true` (:207). The in-code retail model at :357-360 cites "Render::set_view (:343750) installs the view polygon's edge planes and DrawEnvCell submits every cell polygon with planeMask=0xffffffff (:427922)" — i.e. it models the FALLBACK branch as "retail clips drawn CELL geometry," never mentioning use_built_mesh. ISSUES.md:3797-3798 states verbatim "Retail's reference: exact per-poly software clip against the accumulated portal view (planeMask=0xffffffff :427922)" as #114's target.
JUDGMENT — the divergence is real, not behaviorally-equivalent-elsewhere: acdream's #114 charter explicitly aims at pixel-exact geometric shell crops, modeled on retail's use_built_mesh==0 fallback; Ghidra proves production retail draws each cell shell ONCE per frame as a whole prebuilt hardware mesh with no geometric view clipping, the discipline being cell_draw_list admission + portal masking + depth. acdream does have a portal-mask pass (DrawExitPortalMasks, RetailPViewRenderer.cs:325-343), but the shells are still geometrically cropped on outdoor roots and #114 plans to extend cropping indoors — exactly the fallback-chasing the claim describes. Residual uncertainties (honest): (a) a raw by-value SetRenderState(CLIPPLANEENABLE) vtable call without a named symbol can't be fully excluded by grep — but DrawMesh's indifference to view state plus the dedup argument make it moot; (b) I did not verify what polyListFinishInternal does with planeMask=0xffffffff in the fallback branch (irrelevant to the verdict — it's the fallback either way); (c) the claim's proposed one-breakpoint live cdb check on use_built_mesh remains a cheap belt-and-suspenders confirmation but the static evidence (IsRunTime gate + construct-on-unpack) is strong. The port-shape reframing (cell-selection + exit-portal z-masks + depth instead of better crop regions; keep the outdoor crop only as the validated #113 mitigation) follows directly.
- blastRadius: #114 (indoor shell-clip regions not draw-quality: chopped stairs, vanishing walls, neighbour-room barrel). If production retail never geometrically crops shells, the faithful port is cell-selection + portal z-masks + depth — not better crop regions — which reframes the #114 work and the indoor half of #113.
- retailEvidence: RenderDeviceD3D::DrawEnvCell (0x59f170, Ghidra-confirmed): per-frame dedup at entry (GetDrawnThisFrame), then `if (use_built_mesh) { SetStaticLightingVertexColors; D3DPolyRender::DrawMesh(constructed_mesh); return; }` — the planeMask=0xffffffff per-poly submit (pc:427922) is only the else branch. CEnvCell::UnPack constructs the mesh at runtime (ConstructMesh call pc:311085/0x52d87a; pattern mirrors CGfxObj::InitLoad pc:318778-318784 where use_built_mesh=1 on fresh construct success). Cell shells therefore draw ONCE per frame epoch as whole HW meshes; visibility discipline = which cells are in cell_draw_list + portal z-fills + depth.
- acdreamEvidence: DrawEnvCellShells applies gl_ClipDistance crops per slice for outdoor roots and aspires to 'pixel-exact indoor regions' for #114 (RetailPViewRenderer.cs:345-399, scope note :374-377; ISSUES.md #114 cites 'retail's reference: exact per-poly software clip' as the target).
- portShape: Re-baseline #114: verify the use_built_mesh value live (one cdb breakpoint on DrawEnvCell reading [cell+offsetof(use_built_mesh)]), and if confirmed, port the z-mask + cell-selection discipline (exit-portal z-fans + interior draw through constructed views) instead of perfecting geometric crop regions. Keep the outdoor-root crop only if it remains the validated #113 phantom fix.
### [MEDIUM] no-per-slot-viewcone-for-meshes (confirmed) — Entities culled by one camera frustum instead of per-portal-view-slot sphere-vs-cone checks
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), all claims check out:
1. RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860; BN pc:429245, loop body pc:429272-429329 — the claimed range 429245-429271 covers only the header + non-portal branch, immaterial): when Render::PortalList != NULL it loops slot = 0..PortalList->view_count; per slot (gated by `building_view == -1 || building_view == slot`) it calls Render::set_view(&PortalList->view, slot) then Render::viewconeCheck(gfxobj->drawing_sphere). Non-OUTSIDE slots each get a DrawMeshInternal call under that slot's view state; OUTSIDE slots increment a counter (drawn anyway only when the force flag param_3 is set), and when the counter equals view_count the function returns OUTSIDE_VIEWCONE_ODS without drawing. Exactly the claimed "set_view per slot + viewconeCheck per slot, OUTSIDE in all slots → not drawn".
2. Render::viewconeCheck (Ghidra 0x0054c250; pc:342860): transforms the drawing sphere to viewer space and does sphere-vs-plane tests against viewer_world_space.CY plus the current slot's portal plane array (portal_vertex[i].plane, count portal_npnts); returns OUTSIDE / PARTIALLY_INSIDE / ENTIRELY_INSIDE. Render::set_view (0x0054d0e0; pc:343750-343764) is what installs portal_npnts/portal_vertex/portal_inmask + per-slot scissor (xmin..ymax), so the cone is genuinely per-slot.
3. The entity path really goes through this: CPhysicsPart::Draw (0x0050d7a0; pc:274964-275002) calls the virtual RenderDevice->DrawMesh, whose vtable slot (pc:1037075, 0x007e5570) is RenderDeviceD3D::DrawMesh 0x5a0860. So statics, dynamics, AND the player all pass the per-slot check.
4. Player nuance VERIFIED VERBATIM: DrawMeshInternal (Ghidra 0x0059f360) early-returns for parts where CPhysicsPart::GetDrawnThisFrame is set — but only when !CPhysicsPart::IsPartOfPlayerObj(s_current_physics_part). Non-player parts therefore draw in only the FIRST passing slot per frame; player parts are exempt from the dedup and draw once per passing slot (each under that slot's set_view scissor/planes). The claim's "player drawn once per slot, exempt from dedup" is exactly what the binary does.
ACDREAM SIDE — all cited lines check out, and the divergence is slightly WORSE than claimed on the indoor path:
1. WbDrawDispatcher.cs:660-666 (claim said 662-666): the only per-entity view test is FrustumCuller.IsAabbVisible(frustum, AabbMin, AabbMax) against the single camera frustum; animated entities bypass it (line 660-662). No per-slot/per-cone test exists anywhere in the dispatcher.
2. RetailPViewRenderer.cs:460-477 DrawEntityBucket: passes visibleCellIds = {cellId} (membership routing only) — confirmed. STRENGTHENING FINDING: it constructs the entry with LandblockId = ctx.PlayerLandblockId ?? 0u (line 465-466) while also passing neverCullLandblockId: ctx.PlayerLandblockId (line 474), so WbDrawDispatcher.cs:662's `entry.LandblockId != neverCullLandblockId` is false whenever PlayerLandblockId is non-null — the AABB-frustum cull is bypassed ENTIRELY for indoor per-cell buckets. The indoor flooded-cell entities get no view-based cull at all, only the cell-membership gate.
3. RetailPViewRenderer.cs:439-450 (UseIndoorMembershipOnlyRouting): comment is as claimed — it correctly cites retail's viewconeCheck-not-hard-clip behavior for meshes and clears entity clip routing, but no cone ACCEPT test was added in its place. The ClipViewSlice data the port shape needs exists: ClipFrameAssembler.cs:40 defines `record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes)`, and GetCellSlicesOrNoClip (RetailPViewRenderer.cs:428-437) already retrieves per-cell slices (currently used only for particles + shell clip routing).
4. Outdoor production site cross-checked: GameWindow.cs:7827-7830 and 9508-9511 pass the single camera frustum with neverCullLandblockId: playerLb — consistent with the claim's "one camera frustum" characterization for the non-PView path.
JUDGMENT: the divergence is real, not behaviorally equivalent. Retail skips any object whose drawing sphere is outside every portal-view slot's plane set; acdream draws every entity in every flooded drawable cell (and indoors even skips the camera-frustum test). When shells under-occlude (#114 family) those out-of-cone entities become visible artifacts; the player-per-slot multi-draw (with per-slot scissor) has no acdream equivalent. The claimed port shape (CPU-side sphere-vs-slice-planes accept test in DrawCellObjectLists using ClipViewSlice.Planes, skip when outside all slices) is a faithful analogue of viewconeCheck — retail's test is also a CPU-side sphere-vs-plane-set accept, not a GPU clip. Two refinements for the port: (a) retail's check also includes the viewer CY plane in addition to the portal planes; (b) full faithfulness would also reproduce the player's dedup exemption (player drawn per passing slot) and the non-player first-passing-slot draw, per DrawMeshInternal 0x0059f360. Severity "medium" is appropriate: over-draw plus artifact-class visibility contingent on shell under-occlusion, not a standalone top-bug breaker.
- blastRadius: Over-draw of objects in flooded cells that are outside every door cone — normally depth-hidden, but becomes visible artifact whenever shells under-occlude (the #114 family); also the player-in-multiple-views nuance (retail draws the player once per slot, exempt from dedup) has no equivalent.
- retailEvidence: RenderDeviceD3D::DrawMesh loops Render::PortalList->view_count slots, Render::set_view per slot + viewconeCheck(drawing_sphere) per slot, OUTSIDE in all slots → not drawn (0x5a0860, pc:429245-429271; viewconeCheck 0x54c250 pc:342860).
- acdreamEvidence: WbDrawDispatcher per-entity cull is a single AABB-vs-camera-frustum test (WbDrawDispatcher.cs:662-666); the per-cell call passes visibleCellIds={cell} but no per-slice cone (RetailPViewRenderer.cs:460-477). Comment at RetailPViewRenderer.cs:439-450 correctly chose not to hard-clip entities but did not add the cone CHECK retail uses instead.
- portShape: In DrawCellObjectLists, before dispatching a bucket entity, test its bounding sphere against each of the cell's clip slices' plane sets (the data already exists in ClipFrameAssembly); skip the entity when outside all slices. This is a CPU-side accept test, not a GPU clip.
### [MEDIUM] livedynamic-dropped-indoors (refuted) — ServerGuid entities with unresolved ParentCellId are not drawn under indoor roots
- correctedClaim: Not a real divergence. Acdream's LiveDynamic bucket (ServerGuid entity with null ParentCellId) is unpopulated in production: every server-spawned entity gets its full 32-bit wire cell id as ParentCellId at hydration (GameWindow.cs:2836), position-less spawns are dropped before entity creation (GameWindow.cs:2419-2427), and nothing ever nulls the field. Retail, per Ghidra (recalc_cross_cells 0x515a30), treats a cell-less object (objcell_id==0) as shadow-less and therefore undrawn EVERYWHERE — so even if the bucket were populated, the retail-faithful behavior would be to draw it nowhere, making the proposed 'draw LiveDynamic under indoor roots' port anti-retail. The only actionable residue is cleanup: the LiveDynamic bucket + its outdoor-root draw (GameWindow.cs:7716-7724) are dead code guarding an unreachable state, and the explanatory comment is stale.
- verifier notes: RETAIL re-derivation: Ghidra decompile of CPhysicsObj::recalc_cross_cells (0x515a30, via 127.0.0.1:8081) shows the OPPOSITE of the claimed retail evidence: `if (m_position.objcell_id == 0) { if (!m_bExaminationObject) return; if ((state & 0x1000)==0) return; add_particle_shadow_to_cell(this); } else calc_cross_cells(this);` — i.e. retail HAS a 'no cell yet' state, and in it the object registers NO shadows (except the examination-viewport special case). add_shadows_to_cells (0x514ae0, pc:282819) only runs from calc_cross_cells with a populated CELLARRAY. Since retail's world draw is a per-cell walk over each cell's shadow/object lists (DrawInside → cell object lists, pc:433793/:427922), a cell-less object is unreachable by the draw — invisible indoors AND outdoors. The claimed asymmetry ('retail draws them; acdream vanishes them indoors only') mischaracterizes retail: retail draws them nowhere.
ACDREAM re-derivation: the cited mechanism EXISTS — InteriorEntityPartition.cs:35-41 routes ServerGuid!=0 entities with null ParentCellId to LiveDynamic; GameWindow.cs:7716-7724 draws LiveDynamic only when clipRoot.IsOutdoorNode; RetailPViewRenderer.DrawInside consumes only partition.Outdoor (:93/:231) and partition.ByCell (:106/:414), never LiveDynamic. BUT the trigger population is empty by construction: (1) the ONLY ServerGuid!=0 creation site is GameWindow.cs:2826-2837, which always sets ParentCellId = spawn.Position!.Value.LandblockId — the full 32-bit wire ObjCellId (CreateObject.cs:293-294, parsed via ReadUInt32LittleEndian at :399-407), so indoor spawns carry their indoor cell immediately; (2) spawns with null Position are dropped before entity creation (GameWindow.cs:2419-2427 — inventory/held items, no world presence); (3) no code path ever assigns null to ParentCellId afterward (all writes non-null: GameWindow.cs:4481, 4915, 5629, 6798, 8426, 8756, 11506; the dead-reckoning sites guard `if (rm.CellId != 0)` to avoid clobbering); (4) the other new-WorldEntity sites (GameWindow.cs:5258/5463/5622, LandblockLoader.cs:63/79) all leave ServerGuid=0. So there is no 'just-spawned before cell resolve' window — cell assignment is synchronous with hydration from the wire. LiveDynamic is empty in production; the outdoor-root draw is a guard over an unreachable state and its comment (GameWindow.cs:7709-7715) is stale about reachability. No transient invisible NPCs/items, no user-visible asymmetry, severity is nil rather than medium.
- blastRadius: Transient invisible NPCs/items while the viewer is indoors (just-spawned entities before cell resolve); outdoors they draw unclipped, indoors they vanish — an asymmetry retail does not have.
- retailEvidence: Retail objects always occupy a cell and register shadows unconditionally on cell entry (add_shadows_to_cells 0x514ae0 pc:282819; enter_cell/recalc_cross_cells pc:283781) — there is no 'no cell yet, skip draw' state in the draw path.
- acdreamEvidence: GameWindow.cs:7716-7724 draws partition.LiveDynamic only when clipRoot.IsOutdoorNode; the indoor-root branch has no LiveDynamic draw. InteriorEntityPartition.cs:35-40 routes ServerGuid entities with null ParentCellId to LiveDynamic.
- portShape: Resolve a cell for every live entity at spawn/update (the membership machinery exists — P1 matches retail) so LiveDynamic is empty by construction; until then, draw LiveDynamic under indoor roots too (depth + frustum gated), matching the outdoor-root regression guard.
### [LOW] outdoor-objects-redrawn-per-slice (adjusted) — Outdoor bucket + its particles re-drawn once per outside-view slice instead of once under a multi-slot view
- correctedClaim: The headline divergence is not real: retail does NOT draw the outdoor bucket "once under a multi-slot view" — RenderDeviceD3D::DrawMesh (0x5a0860) re-draws each mesh once per outside-view slot that passes that slot's viewcone check, clipped to the slot's exact portal polygon (Render::set_view 0x0054d0e0); the frame-stamp (CPhysicsPart::Draw 0x0050d7a0) only dedups across cell draw lists, not slots. acdream's per-slice landscape loop (RetailPViewRenderer.cs:214-238 + GameWindow.cs:9465-9551) is the behaviorally equivalent loop inversion for all clip-routed geometry (sky/terrain/entities draw under per-slice clip distances + scissor). The surviving, narrower divergence (severity low): the particle sub-passes inside the per-slice callback (GameWindow.cs:9489-9492, 9518-9530, 9533-9541) ignore the slice clip planes and rely on the conservative NDC-AABB scissor alone, so additive/alpha particles can double-blend in the AABB-overlap-minus-portal-overlap region when 2+ exterior portals are on screen; plus a perf-only gap (no per-slice viewcone cull — full camera frustum passed at GameWindow.cs:9508). Correct port shape: KEEP the per-slice loop (it matches retail) and clip the per-slice particle passes to the slice planes (retail's per-slot alpha-poly clip), optionally adding a per-slice entity sphere-vs-slice-planes cull for perf — do NOT collapse to a single union-scissor draw, which would diverge from retail.
- verifier notes: RETAIL re-derived from Ghidra (not BN pseudo-C). (1) PView::DrawCells @ 0x005a4840: confirmed `Render::PortalList = &this->outside_view; LScape::draw(lscape);` runs exactly once when outside_view.view_count != 0, then one FlushAlphaList(0.0) and m_nFrameStamp++ (matches pc:432719/432722). (2) HOWEVER the claim's dedup mechanism is wrong: RenderDeviceD3D::DrawMesh @ 0x005a0860 (Ghidra decompile) iterates PortalList slots and calls DrawMeshInternal once PER slot whose viewconeCheck passes — retail deliberately RE-DRAWS each landscape mesh once per visible outside-view slot; correctness comes from Render::set_view @ 0x0054d0e0 installing each slot's exact portal polygon (vertex list, inmask, xmin/xmax/ymin/ymax) as the active clip region, so per-slot draws are pixel-disjoint up to true portal overlap. (3) The frame-stamp dedup actually lives in CPhysicsPart::Draw @ 0x0050d7a0 (pc:274971: `arg2 != 0 || m_current_render_frame_num != m_nFrameStamp`) and dedups a part across multiple CELL draw lists within a stamp epoch — it does NOT (and cannot) suppress per-slot draws, since the slot loop is below it in DrawMeshInternal's path. LScape::draw @ 0x00506330 confirms blocks are walked once; the per-slot fan-out happens at mesh level. ACDREAM verified: RetailPViewRenderer.cs:214-238 does iterate OutsideViewSlices invoking the full landscape callback per slice (as claimed), with SetTerrainClip(slice.Planes)+UploadClipFrame+SetClipRouting(slice.Slot) before each callback (lines 225-227). The callback DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551, wired at 7624) scissors every slice draw to slice.NdcAabb (9477, disabled 9547-9548) and draws sky (9484-9487), terrain (9494-9496), and the outdoor entity bucket (9503-9512) with clip distances ENABLED against the slice planes — so the per-slice geometry redraw is clipped per slice, which is behaviorally EQUIVALENT to retail's per-slot DrawMeshInternal loop, merely loop-inverted (retail: per mesh, iterate slots; acdream: per slot, iterate meshes). No duplicated-pixel entity draws and no "draw once under multi-slot view" in retail to diverge from. Residual REAL gaps found: (a) the particle sub-passes inside the slice callback (SkyPreScene 9489-9492, outdoor-attached Scene 9518-9530, weather/SkyPostScene 9533-9541) run with clip distances DISABLED, confined only by the conservative NDC-AABB scissor — retail clips alpha-list polys to the exact per-slot portal polygon at transform time; where two slices' AABBs overlap but their portal polys do not, additive/alpha particles double-blend (requires interior viewer + 2+ exterior portals with overlapping screen AABBs; ClipFrameAssembler.cs:134-164 confirms one slice per outside-view polygon, so 2+ slices occur). (b) Perf-only: the dispatcher is invoked per slice with the FULL camera frustum (GameWindow.cs:9508), so entities invisible in a given slice still incur vertex work that gets clipped — retail's per-slot viewconeCheck skips those slots. The CLAIMED port shape (draw outdoor bucket once under a union scissor / any-slice accept) would be LESS retail-faithful: a single draw cannot apply per-slot exact plane sets, and retail's actual architecture IS the per-slot redraw.
- blastRadius: Double-blended (brighter) additive particles and duplicated outdoor entity draws when an interior viewer has 2+ exterior portals on screen; perf overdraw. No single-window artifact.
- retailEvidence: LScape::draw runs once with Render::PortalList=&outside_view (all slots); per-mesh slot iteration + frame-stamp dedup prevents duplicate draws (PView::DrawCells loop 1 pc:432709+; DrawMesh slot loop 0x5a0860).
- acdreamEvidence: DrawLandscapeThroughOutsideView iterates OutsideViewSlices invoking the full landscape callback per slice (RetailPViewRenderer.cs:214-238); the callback draws the whole Outdoor bucket and its attached particles each time (GameWindow.cs:9503-9530).
- portShape: Draw the outdoor bucket once with the union scissor (or a per-entity any-slice accept test) and draw outdoor-attached particles once, not per slice.
### [LOW] per-cell-depth-sort-missing (adjusted) — No per-cell viewer-distance sort of a cell's objects before draw
- correctedClaim: Acdream DOES per-cell viewer-distance sorting (the claim's headline is wrong): each indoor cell bucket gets its own WbDrawDispatcher.Draw call (RetailPViewRenderer.cs:408-477) which sorts translucent groups back-to-front by camera distance (WbDrawDispatcher.cs:1442-1446, :1203-1204) before the blended MDI pass. The REAL residual divergence is sort granularity: retail insertion-sorts every individual shadow part by its own per-part CYpt = 3D viewer distance to the part's scaled sort_center, descending/back-to-front (Ghidra 0x5a0760 DrawObjCellForDummies → 0x5a0690 UpdateObjCell → 0x510b30/0x50e030 UpdateViewerDistance → 0x6b5130 insertion_sort; called per visible cell from PView::DrawCells 0x5a4840, pc:432878), while acdream sorts per (mesh-slice,texture) GROUP keyed on the first instance's matrix origin only — instances within one translucent group are unsorted under blending, and particles render in a separate non-interleaved pass. Severity: low (translucent-within-batch ordering and translucent-vs-particle interleaving only); port shape if ever needed: per-instance distance keys (to part sort_center) within translucent groups rather than the proposed "sort each cell bucket" (already present).
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), every cited element confirmed:
(1) RenderDeviceD3D::DrawObjCellForDummies (Ghidra 0x5a0760): calls UpdateObjCell(cell); then, if the cell's CPartCell sub-object has num_shadow_parts > 1, calls CShadowPart::insertion_sort(shadow_part_list, num_shadow_parts); then calls vtable+0x60. CPartCell layout {vfptr, num_shadow_parts, DArray<CShadowPart*> shadow_part_list} at acclient.h:30889-30894 matches the decompile's piVar1[1]/piVar1+2 access. RenderDeviceD3D vtable base is 0x7e5500 (pc:1037045ff); base+0x60 = 0x7e5560 = DrawObjCell (pc:1037071) — so the sort happens immediately before DrawObjCell, as claimed.
(2) RenderDeviceD3D::UpdateObjCell (Ghidra 0x5a0690): iterates the cell's shadow_object_list calling CPhysicsObj::UpdateViewerDistance per shadow object (two variants split on MAX_CELL_2D_DEGRADE_DISTANCE), as claimed.
(3) CPhysicsObj::UpdateViewerDistance (Ghidra 0x510b30): writes this->CYpt = 3D Euclidean distance from Render::viewer_pos, then propagates to CPartArray → CPhysicsPart::UpdateViewerDistance (Ghidra 0x50e030), which writes per-PART CYpt = distance from viewer to the part's scale-adjusted gfxobj sort_center (CPhysicsPart::CYpt at acclient.h:31153).
(4) CShadowPart::insertion_sort (Ghidra 0x6b5130): sorts the CShadowPart* array on part->CYpt DESCENDING (2-element trace of the shift loop: an element with larger CYpt moves ahead of the pivot) — i.e. far-to-near, back-to-front painter's order. The original claim omitted the direction.
(5) Call chain: PView::DrawCells (0x5a4840, pc:432709) loop 3 walks cell_draw_list in reverse calling render_device->DrawObjCellForDummies per visible cell (0x5a4b0d, pc:432878); also fired for creature_cell (pc:91760) and LScape after_sky_cell (pc:268730). So yes: per visible cell, every frame, distances refreshed then parts sorted back-to-front before the cell's objects draw.
ACDREAM SIDE — the claim's characterization is WRONG in its load-bearing half:
(1) WbDrawDispatcher does NOT rely solely on two-pass alpha-test with unsorted buckets. It sorts BOTH sections every Draw() call: opaque front-to-back (CompareOpaqueSubmissionOrder, src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1436-1440) AND translucent BACK-TO-FRONT (CompareTransparentSubmissionOrder, WbDrawDispatcher.cs:1442-1446: b.SortDistance.CompareTo(a.SortDistance)), applied at :1203-1204. The transparent MDI section alpha-blends with depth-write off (:1346-1348), so this ordering is live, not vestigial.
(2) "Per-cell buckets dispatched as-is" is false. On the indoor path, DrawCellObjectLists walks OrderedVisibleCells in REVERSE (far→near, mirroring retail's reverse cell_draw_list walk) and calls DrawEntityBucket per cell (RetailPViewRenderer.cs:408-421, :460-477 → _entities.Draw at :470). Draw() clears _groups at the top of every call (WbDrawDispatcher.cs:741), so each cell's bucket is independently grouped AND viewer-distance-sorted within that call — i.e. acdream ALREADY HAS a per-cell viewer-distance sort of the cell's objects before draw. The proposed port shape ("sort each cell bucket by distance before dispatch") describes code that exists.
(3) Outdoors, GameWindow.cs:7827 issues one global Draw over all landblock entries — but a single global back-to-front translucent sort is behaviorally equivalent-or-better than retail's per-cell sorts concatenated in traversal order; not an absence.
WHAT SURVIVES (the real, narrower divergence): sort GRANULARITY, not sort existence. Retail sorts every individual CShadowPart by its own per-part CYpt (distance to that part's scaled sort_center, refreshed per frame). Acdream sorts per (mesh-slice, texture) GROUP (GroupKey.cs:14-22) keyed on the squared camera distance to the FIRST instance's matrix translation only — explicitly commented "cheap heuristic" (WbDrawDispatcher.cs:1174-1180). Two consequences: (a) multiple entities/parts sharing one batch collapse to a single sort key taken from whichever instance was appended first, and instances WITHIN a translucent group render in insertion order under blending, unsorted; (b) the distance reference is the part matrix origin, not the gfxobj sort_center. Additionally, particles draw in a separate pass after each cell's bucket (RetailPViewRenderer.cs:423-424; GameWindow.cs:7851/:9570 depth-write off) and never distance-interleave with translucent entity parts, whereas retail's UpdateViewerDistance has a particle-specific branch (state & 0x1000 → particle_distance_2dsq, Ghidra 0x510b30) feeding the same per-cell sorted structure. Severity stays LOW — opaque order is settled by the depth buffer in both engines; the residual affects only translucent-vs-translucent ordering within one batch group and translucent-vs-particle interleaving.
- blastRadius: Translucent statics/dynamics within one room can sort against each other and against particles differently than retail; subtle blending-order differences only.
- retailEvidence: DrawObjCellForDummies insertion-sorts the cell's shadow_part_list by viewer distance every frame before DrawObjCell (0x5a0760, pc:429177; CShadowPart::insertion_sort), after UpdateObjCell refreshes distances (0x5a0690, pc:429129).
- acdreamEvidence: WbDrawDispatcher sorts opaque front-to-back globally and handles translucency via the two-pass alpha-test model (CLAUDE.md N.5 design; per-cell buckets dispatched as-is at RetailPViewRenderer.cs:460-477).
- portShape: Low priority: if blending-order bugs surface, sort each cell bucket by distance before dispatch (cheap — buckets are small).
## OPEN QUESTIONS
- Pixel effect of DrawPortalPolyInternal (0x59bc90, pc:424490): it writes a DEPTHTEST_ALWAYS triangle fan with z forced ~far and a cycling portalColorVal palette with an alpha bit derived from the maxZ1/maxZ2 mode globals — almost certainly a z-mask (it drives portalsDrawnCount → the DrawCells z-clear), but whether any color is ever visible in production (and therefore what the 'closed door' aperture should look like when ConstructView fails) needs a live retail capture of maxZ1/maxZ2 or a RenderDoc-equivalent observation.
- PView::DrawPortal's param_3==3 branch (fill the portal poly when ConstructView FAILS) has no reachable caller I could find — only portal_draw_portals_only calls the vtable slot, passing pass∈{1,2}. If some other entry exists (tool mode? indoor exit portals?), the 'door fills when you can't see through' story changes; treat the failure→no-draw conclusion as production-path-only.
- Production truth of use_built_mesh==1 for EnvCells: the CGfxObj::InitLoad pattern is clean (pc:318778-318784) and CEnvCell::UnPack calls ConstructMesh at runtime (pc:311085), but BN's ADJ-garbled field writes around 0x52d780/0x52d882 make the EnvCell use_built_mesh assignment lower-confidence than the GfxObj one. One cdb read of a live CEnvCell settles it — load-bearing for the shells-drawn-whole divergence and #114's direction.
- Exact runtime mechanism of #114 re-test item 2 (foreign-building candle flames through walls): the structural gap is established (no emitter-own-cell + rectangle scissor + full-screen NoClipSlice fallback + clipRoot-null unfiltered fallback), but which specific admission path fires in the user's repro needs one capture frame — candidates: slot-less cell → full-screen scissor, owner ParentCellId vs actual flame-hook cell mismatch, or a clipRoot-null fallback frame during branch flicker.
- Retail CYpt semantics: assumed 'viewer distance' refreshed by CPhysicsObj::UpdateViewerDistance via UpdateObjCell (0x5a0690) — consistent with usage in ShouldDrawParticles, but the field's exact definition (eye vs object-center, world units) was not independently verified.
- DrawCells loop-1's per-slot DrawPortalPolyInternal for indoor portals with other_cell_id==-1 (pc:432786) — read as the exit-portal z-mask that lets landscape show through doorways; whether acdream's DrawExitPortalMasks (RetailPViewRenderer.cs:325-343) matches its draw state (DEPTHTEST_ALWAYS far-z fan, per view slot) 1:1 was not compared at the GL-state level.
- CBuildingObj leaf_cells (acclient.h:31913) + DrawBuildingLeaf (0x5a07e0; DrawPartCell call pc:429236) draw objects registered in a building's BSP leaf part-cells during the building draw — acdream has no equivalent; which Holtburg content (porch objects? hill-cottage steps?) depends on it is unverified.