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>
43 KiB
AREA 2 — Building shells (CBuildingObj / CBldPortal / DrawBuilding vs acdream's building-as-WorldEntity)
RETAIL
DATA STRUCTURES. A building in retail is a real physics object woven into the cell graph, not a decoration. CBuildingObj : CPhysicsObj (acclient.h:31908) carries exactly three building-specific things: num_portals + CBldPortal** portals (the doorway/window list), num_leaves + CPartCell** leaf_cells (one optional object-holding bucket per drawing-BSP leaf), and a shadow_list of CShadowPart. It is built from the dat's BuildInfo (acclient.h:32035: model id, placement frame, num_leaves, num_portals, portals). CBldPortal (acclient.h:32094) = {int portal_side; uint other_cell_id; int other_portal_id; int exact_match; num_stabs + uint* stab_list; float sidedness}. The dat flag word decodes (CBldPortal::UnPack, Ghidra 0x0053bc40, confirmed against the binary): bit 0 = exact_match, bit 1 INVERTED = portal_side (portal_side = (~flags >> 1) & 1); then other_cell_id (low-16, OR'd with the landblock prefix), other_portal_id (signed; -1 = no matching cell portal), and a stab_list of cell ids — the precomputed set of interior cells potentially visible through this portal. Meanings, read from the code that consumes them: portal_side says which side of the portal polygon's plane you must stand on to see through it — PView::ConstructView(CBldPortal) (0x5a59a0, pc:433750-433792) computes the eye's signed distance to the portal plane (ε=0.0002) and requires NEGATIVE when portal_side!=0, POSITIVE when portal_side==0 (pc:433771-433788); wrong side → return 0, no flood, nothing drawn. exact_match says the two cells share the identical portal polygon, so the view needs no second clip — PView::ClipPortals (0x5a5520) at pc:433655 reads CCellPortal offset +0x14 (exact_match) and +0x10 (other_portal_id): if (exact_match != 0 || other_portal_id < 0) propagate the clipped view directly, else first call PView::OtherPortalClip (0x5a5400, pc:433521) to also clip against the far cell's own portal polygon. There is no "show/no-show" flag — visibility of a portal's surface is decided per frame by the view construction, never by static data. CSortCell : CObjCell (acclient.h:31880) adds one field: CBuildingObj* building — every outdoor land cell can host one building. CEnvCell (acclient.h:32072) carries num_view + DArray<portal_view_type*> portal_view: a STACK of view contexts; portal_view_type (acclient.h:32346) = {DArray<portal_info> portal; view_type view; max_indist; view_count; cell_view_done; timestamps}. A "view" is a screen-space convex polygon + bounding rect: Render::set_view (0x54d0e0, pc:343750-343765) installs portal_view_num=slot, the slot's clip polygon vertices and xmin/xmax/ymin/ymax — every subsequent polygon submission is clipped against it and every mesh is sphere-tested against it (viewconeCheck). That 2D-screen-clip is retail's entire portal-clipping mechanism (software, pre-rasterization — not hardware clip planes).
CONSTRUCTION + CELL-GRAPH MEMBERSHIP (Q4). CLandBlock::init_buildings (0x52fd80, pc:313855-313915): for each BuildInfo → CBuildingObj::makeBuilding(model_id, num_portals, portals, num_leaves) (0x6b53a0, pc:701293-701350: InitPartArrayObject builds the shell's CPhysicsPart from the GfxObj/Setup; leaf_cells allocated num_leaves entries ALL NULL; CBldPortal pointers copied) → set_initial_frame → CBuildingObj::add_to_cell(get_landcell(...)) (0x6b5550, pc:701398: CSortCell::add_building sets cell->building=this (0x534030, pc:318292); set_cell_id; this->cell=cell) → CBuildingObj::add_to_stablist accumulates every portal's stab_list into the landblock's stablist (0x6b51b0, pc:701185). From then on the building participates in EVERYTHING through its host cell: CSortCell::find_transit_cells (0x534060, pc:318309) → CBuildingObj::find_building_transit_cells (0x6b5230, pc:701214) → per portal CBldPortal::GetOtherCell → CEnvCell::check_building_transit(other_portal_id, …) (0x52c5d0, pc:309827-309860: gate other_portal_id >= 0, then each sphere transformed to the cell's local frame and tested with CCellStruct::sphere_intersects_cell; hit → sphere_path.hits_interior_cell=1 + CELLARRAY::add_cell) — outdoor→indoor membership promotion. CSortCell::find_collisions (0x5340a0, pc:318337) → CBuildingObj::find_building_collisions (0x6b5300, pc:701260: sets sphere_path.bldg_check=1, runs the shell part's PHYSICS BSP). CSortCell::get_object even searches INTO the interior cells recursively through the building's portals (0x5340c0, pc:318346 → CEnvCell::recursively_get_object, pc:701421). leaf_cells: the render hook RenderDeviceD3D::DrawBuildingLeaf (0x5a07e0, pc:429223-429240) draws CBuildingObj::curr_leaf_cells[i] via DrawPartCell with pushLevelOffset=1 — but Ghidra xrefs to the curr_leaf_cells global (0x8fa9bc) show READS ONLY, no writer found; the per-leaf object-bucket path appears dormant in the 2013 build.
DRAW CHAIN FROM OUTSIDE (Q2). Top of frame: SmartBox::RenderNormalMode (0x453aa0, pc:92636-92690) — viewer in an EnvCell → render_device->DrawInside(viewer_cell) (pc:92675); viewer outdoors → Render::set_default_view() (full-screen view, no PortalList) + LScape::draw (pc:92683). LScape::draw (0x506330, pc:267912-267950): GameSky::Draw(0), then per landblock render_device->DrawBlock (pc:267937). RenderDeviceD3D::DrawBlock (0x5a17c0, pc:430027-430143): per land cell in draw_array, if in view: DrawLandCell (terrain polys) then DrawSortCell (pc:430124). RenderDeviceD3D::DrawSortCell (0x59f140, pc:427872-427884): if (cell->building) DrawBuilding(building); DrawObjCell(cell) — a building draws exactly when its host land cell draws. RenderDeviceD3D::DrawBuilding (0x59f2a0, pc:427938-427961) does four things: (1) hands the building's CBldPortal array to the outdoor PView: outdoor_pview->outdoor_portal_list = building->portals (pc:427940) — this is the lookup table the shell's portal polys will index; (2) sets building detail surface + FlushAlphaList; (3) portal pass: CPhysicsPart::Draw(part0, 1) (pc:427955); (4) mesh pass: ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part0, 0) (pc:427957). Both passes funnel through RenderDeviceD3D::DrawMesh (0x5a0860, pc:429247-429320), which is where the "portal-view slot" machinery lives: if Render::PortalList is null (plain outdoor view) → one viewconeCheck(drawing_sphere) + one DrawMeshInternal; if PortalList is non-null (e.g. the landscape is being drawn through doorway views from an indoor root) → loop i over PortalList->view_count: if (building_view == -1 || building_view == i) { Render::set_view(&PortalList->view, i); viewconeCheck; DrawMeshInternal } (pc:429290-429310) — the shell is drawn once per surviving view slot, clipped to that slot's screen polygon. building_view is a latch: DrawMeshInternal's portal pass sets it to the current slot while walking the shell BSP (pc:427988), and RenderDeviceD3D::DrawPortal saves/sets-(-1)/restores it around the nested flood (0x59f0e0, pc:427906-427914) — binding each building's flood to the view slot it was discovered under. DrawMeshInternal (0x59f360, pc:427966-428000): portal pass → BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (drawing_bsp, 2) (pc:427993-427994); mesh pass → D3DPolyRender::DrawMesh(constructed_mesh) — the whole prebuilt shell mesh (the BSP was pruned to portal-only nodes at load by CGfxObj::InitLoad → BSPTREE::RemoveNonPortalNodes, 0x5346b0, pc:318766-318787).
PORTAL POLYS AND THE INDEX CORRESPONDENCE (Q3). The drawing BSP's portal nodes are BSPPORTAL : BSPNODE = {num_portals; CPortalPoly** in_portals} (acclient.h:57768); CPortalPoly = {unsigned portal_index; CPolygon* portal} (acclient.h:39075). BSPPORTAL::UnPackPortal (0x53db70, pc:327167-327256) shows the dat wire format exactly matching the e223325 finding: per portal node, num_polys ordinary polys (node.Polygons) + num_portals pairs, each pair = {portal = &polygons[PolyId], portal_index = PortalIndex}. build_draw_portals_only (Ghidra 0x53c100) walks the BSP front-to-back by the viewpoint's side of each splitting plane; at each PORT node BSPPORTAL::portal_draw_portals_only (0x53d870, pc:326881-327058) calls render_device->DrawPortal(in_portals[i], 1, pass) for every portal poly (pc:326921). PView::DrawPortal (0x5a5ab0, pc:433895-433940) resolves CBldPortal bld = this->outdoor_portal_list[portalPoly->portal_index]* (pc:433920) — THE index correspondence: the GfxObj's PortalRef.PortalIndex indexes the BuildInfo.Portals/CBldPortal array of the building instance being drawn. It then PView::add_views(bld->num_stabs, bld->stab_list) (pc:433922 — pushes a fresh portal_view slot onto every stab cell via curr_view_push; 0x5a5210, pc:433382), calls ConstructView(bld, poly, 1, pass) (pc:433924), and on success with pass==2 calls PView::DrawCells (pc:433934); finally remove_views pops the slots. ConstructView(CBldPortal) (0x5a59a0): portal_side gate → PView::GetClip (clip the portal polygon against the CURRENT view; empty → fail) → CEnvCell::GetVisible(other_cell_id) (0x52dc10, pc:311378 — hash lookup of loaded+visible cells; not loaded → fail) → Render::copy_view(targetCell->portal_view[top], clip) → if pass != 2: D3DPolyRender::DrawPortalPolyInternal(poly, pass==1); if pass != 1: recursive ConstructView(CEnvCell) (0x5a57b0, pc:433749 — the BFS flood: reset outside_view, InitCell, ClipPortals/AddViewToPortals loop building cell_draw_list). So pass 1 = construct views + portal-poly mask; pass 2 = flood + draw interior cells. What DrawPortalPolyInternal actually draws (Ghidra 0x59bc90, decisive): an UNTEXTURED triangle fan (SetStageTexture null) whose vertex alpha byte computes to 0x00 for both config values (maxZ1=7, maxZ2=6: ~(maxZ<<30) & 0x80000000 = 0) — i.e. an INVISIBLE quad — drawn DEPTHTEST_ALWAYS with depth-write on (bit 2), at the FAR plane z=0.999999 when flag=1 (maxZ1 bit 0) or at the polygon's real depth when flag=0 (maxZ2). It is a depth punch/seal, not a visible door. It also skips polys whose vertices all lie on a ±12 m cell-boundary plane (dungeon seam portals). portalsDrawnCount increments only for flag=0; PView::DrawCells (0x5a4840, pc:432706-432800) uses it to gate a depth clear: PortalList=&outside_view → LScape::draw (the whole landscape re-drawn through the accumulated doorway views, pc:432719) → Clear(depth) if any portal was drawn (pc:432730) → reverse (far-to-near) per-cell loop 1: per view slot setup_view + DrawPortalPolyInternal(poly, 0) for every cell portal with other_cell_id==0xFFFFFFFF (leads outside) (pc:432783-432786) — re-sealing doorway depth — → loop 2: cell shells + contents per slot.
FLOOD DECISION FROM OUTSIDE (Q5). Retail floods a building's interior exactly when, during the normal draw of the building's shell, the drawing-BSP portal walk reaches a portal poly AND PView::ConstructView succeeds: (a) eye on the portal_side-correct side of the portal plane; (b) the portal polygon clipped against the CURRENT view (full screen outdoors; a doorway slot view if the building is itself seen through a doorway) is non-empty — this is the only "distance" gate retail has (far buildings fail viewconeCheck or clip to nothing sub-pixel… they never fail by a distance constant); (c) CEnvCell::GetVisible(other_cell_id) — target cell loaded. The flood then BFSes the interior cell graph (ConstructView(CEnvCell) → ClipPortals/AddViewToPortals) and DrawCells draws it. Note the outdoor PView was constructed with draw_landscape=0 (PView ctor 0x5a5270 pc:433413-433444; outdoor_pview=PView(0) pc:427813, indoor_pview=PView(1) pc:427800), so floods INTO buildings never accumulate outside views — you don't recursively re-draw the landscape through a building's back door; only the indoor pview (rooted at the viewer's cell) draws landscape through portals.
ACDREAM
SPAWN / DATA MODEL. One WorldEntity per BuildInfo with the whole shell model as a flat mesh: LandblockLoader.BuildEntitiesFromInfo (src/AcDream.Core/World/LandblockLoader.cs:75-92) creates WorldEntity { SourceGfxObjOrSetupId = building.ModelId, IsBuildingShell = true, BuildingShellAnchorCellId = first non-0xFFFF portal cell } with id 0xC0XXYY00+n; ParentCellId stays null. The BuildInfo.Portals list is consumed in three other places, never by the shell draw: (1) GameWindow.cs:5959-5996 caches BldPortalInfo{otherCellId, otherPortalId, flags} per host landcell via PhysicsDataCache.CacheBuilding (src/AcDream.Core/Physics/PhysicsDataCache.cs:433) for physics; (2) BuildingLoader.Build (src/AcDream.App/Rendering/Wb/BuildingLoader.cs:53-140) BFSes cell portals to derive each Building's EnvCellIds + exit-portal polygons + occlusion-query state (src/AcDream.App/Rendering/Wb/Building.cs), registered per landblock at GameWindow.cs:6001-6045; (3) the anchor cell id. Interior cells are hydrated by GameWindow.BuildInteriorEntitiesForStreaming (GameWindow.cs:5488+): per EnvCell, CellMesh.Build + EnvCellRenderer.RegisterCell; cell statics become 0x40xxxxxx WorldEntities.
SHELL MESH EXTRACTION. ObjectMeshManager.PrepareGfxObjMeshData (src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1040-1056) iterates the GfxObj Polygons DICTIONARY — every poly, including the portal-fill door/window quads and the meeting-hall stair-aperture quads (e223325: every dictionary poly absent from node.Polygons is a portal poly). The #113 DrawingBSP filter is explicitly NOT applied (comment at ObjectMeshManager.cs:1019-1040; e46d3d9 reverted by 124c6cb after the door regression); CollectDrawingBspPolygonIds (ObjectMeshManager.cs:1004-1010) walks only node.Polygons and never reads node.Portals. Cell-struct meshes by contrast suppress portal polys via dat NoPos/NoNeg stippling (ObjectMeshManager.cs:1385-1400).
DRAW PATH. clipRoot = viewerRoot ?? _outdoorNode (GameWindow.cs:7497; OutdoorCellNode.cs builds a portal-less synthetic outdoor cell). RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-113): PortalVisibilityBuilder.Build floods from the root; for outdoor roots MergeNearbyBuildingFloods (lines 115-149) groups nearby cells by BuildingId and runs PortalVisibilityBuilder.ConstructViewBuilding per building with OutdoorBuildingSeedDistance = 48f (line 30); the candidate cells come from a Chebyshev<=1 landblock gather (GameWindow.cs:7461-7477). ConstructViewBuilding = BuildFromExterior (PortalVisibilityBuilder.cs:390-538, 548-554): seeds from every CELL exit portal (OtherCellId==0xFFFF) whose nearest vertex is within 48m and whose outside face the camera is on, clips the portal polygon in screen space (ClipPortalAgainstView), then BFSes inward with reciprocal clipping (ApplyReciprocalClip skips when dat flag bit 0 ExactMatch is set, lines 775-789; side test via plane.InsideSide, line 740). Draw order in DrawInside: DrawLandscapeThroughOutsideView (lines 214-241: per OutsideView slice → SetTerrainClip + SetClipRouting(slot) + DrawRetailPViewLandscapeSlice — GameWindow.cs:9465-9552: scissor to slice NDC AABB, sky/terrain/outdoor entities incl. building shells with gl_ClipDistance enabled, visibleCellIds:null so shells pass on frustum only); DrawExitPortalMasks (325-343, depth-seal port of DrawCells loop 1); DrawEnvCellShells (345-400: reverse far-to-near, per cell per slice, gl_ClipDistance enabled ONLY for outdoor-eye roots — clipShells gate, #114 scope per 9ce335e); DrawCellObjectLists (401-427: cell statics drawn UNCLIPPED by design; particles per slice via scissor-rect only, GameWindow.cs:9555-9582). Shells draw through WbDrawDispatcher (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:592-685): landblock AABB + per-entity AABB frustum cull; EntityPassesVisibleCellGate (1816-1837) makes shells (ParentCellId null) FAIL any cell filter — they draw only in the visibleCellIds:null landscape pass. InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs) routes shells to the Outdoor bucket. For INDOOR roots, NearbyBuildingCells is null (GameWindow.cs:7610) — no other building's interior is ever flooded from inside; ClearDepthSlice is null for outdoor roots by design (GameWindow.cs:7644-7654). DrawPortal (RetailPViewRenderer.cs:162-212) is a separate outdoor look-in product driven by _exteriorPortalCandidateCells (GameWindow.cs:7778).
PHYSICS. CellTransit.CheckBuildingTransit (src/AcDream.Core/Physics/CellTransit.cs:353-398): per cached BldPortalInfo, foot-sphere vs the target cell's WHOLE CellBSP (BSPQuery.SphereIntersectsCellBsp), invoked from the ResolveCellId path (CellTransit.cs:633). Shell collision comes from per-part ShadowObjectRegistry registration of the 0xC0-prefixed shell entity (GameWindow.cs:6122-6135 + Task-7 loop), a landblock-wide registry rather than retail's cell→building dispatch (the known A6.P4 debt).
DIVERGENCES
[CRITICAL] portal-polys-baked-unconditional (UNVERIFIED (verifier hit token limit)) — Shell portal polys (doors/windows/apertures) baked into the static mesh and drawn unconditionally; PortalRef.PortalIndex → CBldPortal pairing never consumed
- blastRadius: #113 phantom staircase (the meeting-hall stair-aperture portal quads {0,1} draw as visible ramps); the
e46d3d9door regression in BOTH directions (filter on → doors vanish because the visible 'door' IS the baked portal quad; filter off → phantom apertures return) — proves no static filter can be correct; predicted artifact: a server door swinging open still leaves the baked fill quad sealing the doorway behind it (double-representation of doors); interiors can never become visible through window apertures. - retailEvidence: Portal polys live only on BSPPORTAL nodes as CPortalPoly{portal_index, portal} (acclient.h:57768, :39075; BSPPORTAL::UnPackPortal 0x53db70 pc:327167-327256 maps dat PortalRef {PolyId, PortalIndex} onto them). They are dispatched PER FRAME: build_draw_portals_only (Ghidra 0x53c100) → portal_draw_portals_only (0x53d870 pc:326921) → DrawPortal → PView::DrawPortal resolves outdoor_portal_list[portal_index] = the building's CBldPortal (0x5a5ab0 pc:433920) → ConstructView decides per frame (portal_side gate pc:433771-433788, screen clip, GetVisible) whether to flood the interior (pass 2 → DrawCells pc:433934) — and the only direct portal-poly draw is DrawPortalPolyInternal (Ghidra 0x59bc90), an INVISIBLE (alpha=0) depth-punch/seal quad, never a textured surface.
- acdreamEvidence: ObjectMeshManager.PrepareGfxObjMeshData iterates the whole Polygons dictionary into the static mesh (ObjectMeshManager.cs:1040-1056); the DrawingBSP filter is documented as NOT applied (ObjectMeshManager.cs:1019-1040); node.Portals (PortalRef) is never read (CollectDrawingBspPolygonIds walks only node.Polygons, ObjectMeshManager.cs:1004-1010). BuildInfo.Portals is consumed only by physics + BuildingLoader (GameWindow.cs:5959-5996, BuildingLoader.cs:53-140), never paired with the shell GfxObj's PortalIndex.
- portShape: Split GfxObj mesh extraction into (a) the unconditional set = union of node.Polygons across the DrawingBSP and (b) one separately-batched aperture quad per PortalRef, keyed by PortalIndex. At draw time pair PortalIndex with the owning BuildInfo.Portals[i] (the retail outdoor_portal_list correspondence) and run the retail decision per frame: ConstructView-success → suppress the quad's textured draw, flood + draw the interior, write the far-Z punch; failure → seal (depth and/or the quad — pending the open question on whether retail ever draws the textured fill). The swinging-door entity remains the visible door.
[CRITICAL] no-per-slot-building-draw (UNVERIFIED (verifier hit token limit)) — Building never draws per portal-view slot and never spawns floods from its own shell; one frustum-culled draw + geometric 48m seeding instead
- blastRadius: #109 far-door oscillation (acdream's 48m seed-distance constant + per-frame BFS replaces retail's screen-clip-survival gate — a binary distance threshold the eye crosses); looking out of one building at another building's open doorway shows the baked fill instead of its interior (indoor roots pass NearbyBuildingCells=null); #114 indoor clip-region quality (acdream's slot system is a flat per-frame slice table, not retail's per-cell portal_view STACK pushed/popped around each building flood via stab lists).
- retailEvidence: RenderDeviceD3D::DrawMesh loops every view slot with set_view + viewconeCheck and the building_view latch (0x5a0860 pc:429290-429310); DrawMeshInternal binds the shell's portal walk to the current slot (building_view = portal_view_num, pc:427988); PView::DrawPortal pushes a fresh portal_view slot onto every stab cell before flooding and pops after (add_views/remove_views 0x5a5210 pc:433382, pc:433922/433939); the flood trigger is the shell's OWN drawing-BSP portal walk under whatever view the shell is being drawn in (DrawBuilding pass 1, 0x59f2a0 pc:427955 → pc:427993-427994).
- acdreamEvidence: Shells draw once via WbDrawDispatcher frustum cull in the landscape slice (DrawRetailPViewLandscapeSlice GameWindow.cs:9503-9512, visibleCellIds:null; gate at WbDrawDispatcher.cs:1816-1837); per-building floods are seeded geometrically: every exit portal within OutdoorBuildingSeedDistance=48f of the eye, outdoor roots only (RetailPViewRenderer.cs:30,115-149; PortalVisibilityBuilder.cs:404-451; NearbyBuildingCells=null for interior roots at GameWindow.cs:7610).
- portShape: Make the building's shell draw the flood trigger: when the shell entity survives the cull for a given view slice, walk its (restored, portal-only) DrawingBSP portal list under that slice's clip region; each surviving aperture launches ConstructView(CBldPortal) for that slice, replacing the 48m constant with retail's screen-clip gate. Give cells a portal_view slot stack (push via the CBldPortal stab_list before each building flood, pop after) so the same cell seen in two contexts holds two views, and bind each flood to its originating slot (the building_view latch).
[HIGH] flood-gate-shape (adjusted) — Flood admission gate: distance constant + camera-side heuristic vs retail's portal_side + screen-clip + GetVisible chain
- correctedClaim: Flood admission gate (corrected): retail admits a building-interior flood ONLY through the draw path — building must survive per-view viewconeCheck on its drawing_sphere (RenderDeviceD3D::DrawMesh 0x5a0860) before its portal-BSP walk (BSPPORTAL::portal_draw_portals_only 0x53d870) ever submits a portal; then ConstructView(CBldPortal) 0x5a59a0 gates on eye-side vs portal_side (eps=0.0002), GetClip vs the current view polygon (empty = no flood, NO exceptions), CEnvCell::GetVisible (loaded-cell hash 0x52dc10), and copy_view!=0; no distance constant exists anywhere on the chain. acdream already implements faithful analogues of the side test (CameraOnInteriorSide, PortalVisibilityBuilder.cs:423-424/734-741, eps=0.01), the seed-time screen clip (:430-435), and the loaded-cell check (:510-512); the REAL divergences are (1) an added 48m hard seed threshold with no retail counterpart (RetailPViewRenderer.cs:30/141-142, GameWindow.cs:7795) producing binary interior pop at ~48m — the plausible (unproven-at-48m) #109 mechanism; (2) no per-building view-cone pre-gate, costing per-portal arithmetic over Chebyshev<=1 landblocks of candidates (GameWindow.cs:7461-7477) but NOT extra BFS floods (empty seed clip already rejects behind-the-viewer portals); and (3) the EyeInsidePortalOpening full-screen fallback (:437-442/:501-507) which ADMITS floods retail's empty-clip rule strictly rejects — a non-retail bypass the port must retire by fixing near-eye clip degeneracy natively. Port shape stands: per-building portal walk under the active view inherits cone+clip gating, drop the 48m constant, keep ClipPortalAgainstView/ApplyReciprocalClip incl. the exact_match skip (:777-789), and remove the eye-standing bypass. Severity: high (confirmed visible pop mechanism; #109 linkage plausible, #108 speculative).
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), all claimed gates CONFIRMED: (1) PView::ConstructView(CBldPortal*) @ 0x5a59a0 (Ghidra): eye-side of the portal polygon's plane computed against Render::FrameCurrent->viewer.viewpoint with epsilon ::F_EPSILON = 0.000199999995 (~0.0002, BN pc:433848 shows the literal); portal_side==0 requires POSITIVE side, portal_side!=0 requires NEGATIVE — exactly as claimed. Then GetClip(side, portalPoly, clip_view, &outPoly, viewIdx); outPoly==null (empty clip) -> return 0, NO flood. Then CEnvCell::GetVisible(other_cell_id) must be non-null; 0x52dc10 (Ghidra) confirms GetVisible is a pure hash lookup in visible_cell_table, i.e. "cell loaded/registered". Then Render::copy_view must return nonzero (sub-pixel/duplicate view rejection) before recursing into ConstructView(CEnvCell) @ 0x5a57b0. NO distance constant anywhere. (2) Upstream chain confirmed: RenderDeviceD3D::DrawBuilding @ 0x59f2a0 (Ghidra) sets outdoor_pview->outdoor_portal_list = building->portals and draws via CPhysicsPart::Draw; portal polys are submitted by the building's portal-BSP walk BSPPORTAL::portal_draw_portals_only @ 0x53d870 (Ghidra: plane-side recursion, render_device vtable+0x4c per in_portal, F_EPSILON only, no distance) -> RenderDeviceD3D::DrawPortal @ 0x59f0e0 -> PView::DrawPortal @ 0x5a5ab0 (xref-verified) -> ConstructView(CBldPortal). Citation nit: 0x5a0860 is RenderDeviceD3D::DrawMesh (not "DrawBuilding"); it DOES gate per active view with Render::viewconeCheck(gfxobj->drawing_sphere) (Ghidra, both PortalList==0 and per-view loop branches), so far/off-screen buildings never draw and their portal polys are never encountered — far-building dropout = view-cone + clip-to-nothing, never a distance constant. Claim materially right, symbol name wrong. ACDREAM SIDE — production path confirmed by reading code: GameWindow.cs:7459-7478 gathers candidate cells from Chebyshev<=1 landblocks into _outdoorNodeBuildingCells; :7610 passes it as NearbyBuildingCells only on outdoor-node frames; RetailPViewRenderer.cs:60,115-145 groups by BuildingId and calls ConstructViewBuilding(group, ..., OutdoorBuildingSeedDistance) with the constant 48f at RetailPViewRenderer.cs:30 (second call site: GameWindow.cs:7795 MaxSeedDistance=48f -> RetailPViewRenderer.cs:166-171). ConstructViewBuilding (PortalVisibilityBuilder.cs:548-554) = BuildFromExterior; per exit portal the seed gate is: CameraOnInteriorSide skip (:423-424; impl :734-741, cell-local plane + InsideSide + PortalSideEpsilon=0.01f at :38), NearestPortalVertexDistance > maxSeedDistance skip (:426-428), THEN ClipPortalAgainstView vs FullScreenRegion (:430-435) with empty-clip -> skip UNLESS EyeInsidePortalOpening, which substitutes a FULL-SCREEN region (:437-442; same bypass on inner hops :501-507). Inner-hop loaded check lookup(neighbourId)==null -> skip (:510-512). JUDGMENT — the divergence is REAL but the claim's framing of acdream is wrong in three load-bearing ways: (a) acdream's seed gate is NOT "distance + camera-side heuristic" in opposition to retail's "portal_side + screen-clip + GetVisible": acdream ALREADY has all three retail gates at seed time — CameraOnInteriorSide IS the portal_side analogue (epsilon 0.01 vs retail 0.0002, both cell-local plane-side tests), the screen clip runs AT the seed (:430-435, not merely "once seeded"), and the loaded-cell check is the GetVisible analogue. The genuine deltas are narrower: (i) the ADDED 48m hard threshold with no retail counterpart; (ii) the MISSING upstream per-building view-cone rejection (retail kills whole buildings on a sphere-vs-cone test before touching any portal); (iii) a delta the claim missed that cuts the other way: the EyeInsidePortalOpening full-screen fallback (:437-442, :501-507, EyeStandingPerpDist=1.75m at :869) ADMITS floods retail strictly rejects (retail returns 0 on empty clip, no bypass) — a non-retail workaround for near-eye projection degeneracy that retail's homogeneous GetClip handles natively. (b) Blast radius "excess flood work for buildings behind the viewer ... before any BFS" is overstated: behind-the-viewer portals project to <3 clip verts -> empty region -> the seed is SKIPPED (:430-442), so no BFS flood occurs; the excess is per-portal side-test/distance/projection arithmetic across up to 9 landblocks of candidate cells, plus the eye-standing bypass edge case. Retail rejects per-building, acdream per-portal — a cost divergence, not an admission-set divergence for that case. (c) #109: the 48m gate is a strict binary pop (seed admitted iff dist<=48 at :427, view grown-once at :445-448), and retail's equivalent is a smooth clip shrink + cone fade — the popping MECHANISM is confirmed; but nothing in the evidence ties #109's observed oscillation distance to 48m specifically (no repro measurement examined), and the "grazing angles" sub-claim is weak because both implementations run the same clip at grazing angles (the only residual is the 0.01-vs-0.0002 epsilon band, ~1cm at the portal plane). #108 downstream linkage remains speculative as the claim itself says. Port-shape check: sound — under a per-building portal walk driven by the active view, the distance constant becomes droppable and cone/clip gating is inherited; exact_match skip exists at PortalVisibilityBuilder.cs:777-789 (code comment cites decomp:433689; claim's pc:433655 is the ClipPortals gate a few lines up — same mechanism, not load-bearing). Caveat to add: the port must ALSO retire the EyeInsidePortalOpening full-screen bypass (retail: empty clip = no flood, period) by making the seed clipper robust at near-eye degeneracy, or the one-drawing-discipline invariant is still violated from the other side.
- blastRadius: #109 (oscillation exactly at the 48m boundary and at grazing angles where retail's clip would shrink smoothly to nothing); #108 grass-sweep plausibly downstream (outside-slice set changes as floods pop in/out, re-routing the terrain clip); excess flood work for buildings behind the viewer that retail's view clip rejects before any BFS.
- retailEvidence: ConstructView(CBldPortal) 0x5a59a0 pc:433750-433792: (a) eye side vs portal_side (ε=0.0002), (b) GetClip against the CURRENT view polygon — empty clip = no flood; no distance constant exists, (c) CEnvCell::GetVisible (0x52dc10 pc:311378) = cell loaded/registered. Far buildings drop out via viewconeCheck on drawing_sphere (0x5a0860 pc:429297) and sub-pixel clips.
- acdreamEvidence: BuildFromExterior seeds on NearestPortalVertexDistance <= maxSeedDistance (48m) + CameraOnInteriorSide outside-face test (PortalVisibilityBuilder.cs:425-429,404-410); candidate gather is Chebyshev<=1 landblocks (GameWindow.cs:7461-7477). The screen-space ClipPortalAgainstView IS faithful once seeded (PortalVisibilityBuilder.cs:430-445).
- portShape: Drop the distance constant once divergence #2 lands (the trigger becomes the shell's portal walk under the active view, which inherits retail's frustum/clip gating for free); keep the existing ClipPortalAgainstView/ApplyReciprocalClip machinery — it already matches GetClip/OtherPortalClip incl. the exact_match skip (PortalVisibilityBuilder.cs:775-789 vs pc:433655).
[HIGH] aperture-depth-machinery (UNVERIFIED (verifier hit token limit)) — Missing far-Z punch for outdoor building apertures; particles clipped by scissor-rect only
- blastRadius: particles-through-walls (cell particles are gated by an axis-aligned scissor rectangle, not the portal view polygon — emitters near a doorway bleed outside the aperture's true shape); ordering fragility at apertures: acdream relies on shell-then-interior draw order + depth test, retail explicitly punches far-Z through each visible aperture (pass 1) before the interior draws and re-seals indoor doorways at real Z (DrawCells loop 1) with a portal-gated depth clear in between.
- retailEvidence: DrawPortalPolyInternal Ghidra 0x59bc90: alpha=0 fan, DEPTHTEST_ALWAYS, depth-write on, z=far when flag=1 (building pass 1, maxZ1=7) / real z when flag=0 (indoor seal, maxZ2=6); DrawCells 0x5a4840: LScape::draw through outside_view (pc:432719) → Clear(depth) gated on portalsDrawnCount (pc:432730) → per-slot outside-portal seal draws (pc:432783-432786).
- acdreamEvidence: DrawExitPortalMasks ports the indoor seal (RetailPViewRenderer.cs:325-343); ClearDepthSlice is intentionally null for outdoor roots (GameWindow.cs:7644-7654); there is no outdoor-pass far-Z punch anywhere; DrawRetailPViewCellParticles uses BeginDoorwayScissor(NdcAabb) only (GameWindow.cs:9555-9582).
- portShape: Once aperture quads are separate batches (#1), emit them as depth-only far-Z punches on flood success (flag=1 semantics) and real-Z seals on the indoor path (flag=0), matching maxZ1/maxZ2 bit semantics; route particle draws through the same per-slice clip planes the shells use (gl_ClipDistance), not the scissor AABB.
[MEDIUM] building-not-in-physics-cell-graph (confirmed) — Building is not a cell-graph member on the physics side: landblock-wide shadow registry + portal-id gate missing in CheckBuildingTransit
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C) for every branch/sign-sensitive point:
-
Per-cell building dispatch: CSortCell::add_building (Ghidra 0x534030) stores the building on the cell with first-wins semantics (
if (this->building == 0)); CSortCell::find_transit_cells (0x534060 sphere variant, 0x534080 part variant) tail-calls CBuildingObj::find_building_transit_cells; CSortCell::find_collisions (0x5340a0) tail-calls CBuildingObj::find_building_collisions (returns OK_TS when no building); CSortCell::get_object (0x5340c0) falls back to CBuildingObj::get_object. All five Ghidra-decompiled; the cited pc:318309-318362 window matches. CLandBlock::init_buildings (Ghidra 0x52fd80) confirms registration: makeBuilding → adjust_to_outside on the building frame origin → get_landcell → CBuildingObj::add_to_cell(building, (CSortCell*)landcell) AND CBuildingObj::add_to_stablist(&this->stablist, ...). Both registration claims check out. -
The portal-id gate: CEnvCell::check_building_transit (Ghidra 0x52c5d0) opens with
if ((-1 < param_1) && ...)— i.e. skip when other_portal_id < 0 — then per-sphere Frame::globaltolocal + CCellStruct::sphere_intersects_cell(this->structure, ...) and on overlap sets SPHEREPATH::hits_interior_cell=1 + CELLARRAY::add_cell. The caller CBuildingObj::find_building_transit_cells (Ghidra 0x6b5230) confirms the gated argument ISportal->other_portal_id(via CBldPortal::GetOtherCell null-check first). CBldPortal.other_portal_id is signedint(acclient.h:32100); the ctor (Ghidra 0x53bb30) initializes it to -1. -
NEW STRENGTHENING EVIDENCE the claim didn't have: CBldPortal::UnPack (Ghidra 0x53bc40) reads the dat's 16-bit field as a SIGNED short and sign-extends:
sVar3 = *(short *)*param_2; this->other_portal_id = (int)sVar3;— so a dat value of 0xFFFF becomes -1 at runtime and the >= 0 gate is LIVE for dat content, not just for default-constructed portals. Notably the BN pseudo-C (pc:325027) renders this read as*(uint16_t*)(zero-extend), which would have implied the gate is dead code for dat data — exactly the invented-sign failure mode this project has been burned by. The Ghidra decompile settles it in favor of the claim. Same sign-extension in CCellPortal::UnPack (Ghidra 0x53bab0). CCellStruct::sphere_intersects_cell (pc:317666 region, 0x533900) tail-calls BSPTREE::sphere_intersects_cell_bsp(this->cell_bsp) — so acdream's CellBSP-based test is the matching half; the gate is the only in-function divergence.
ACDREAM SIDE — read at the cited locations plus production call sites:
-
CellTransit.CheckBuildingTransit (src/AcDream.Core/Physics/CellTransit.cs:353-398):
foreach (var portal in building.Portals)with NO portal-id test of any kind; skips only onGetCellStruct(portal.OtherCellId)?.CellBSP?.Root is null; tests BSPQuery.SphereIntersectsCellBsp(otherCell.CellBSP.Root, localCenter, sphereRadius). BldPortalInfo.OtherPortalId isushort(src/AcDream.Core/Physics/BuildingPhysics.cs:37) — retail's -1 sentinel is unrepresentable — and the field is never consumed anywhere in src/ (grep: only stored at GameWindow.cs:5972 from the dat'sbp.OtherPortalId). Production callers confirmed: PhysicsEngine.cs:378-382 and CellTransit.cs:631-633, both keyed by outdoor landcell id via PhysicsDataCache.GetBuilding (PhysicsDataCache.cs:445; CacheBuilding at :433-444, first-wins per landcell id). -
Building shell collision is NOT dispatched through any cell-graph member: the 0xC0XXYY00 landblock-stab entity registers EACH physics part into ShadowObjectRegistry (GameWindow.cs ~6132-6190; Register call
6178 with partId = entity.Id*256+partIndex, cellScope: entity.ParentCellId ?? 0u — outdoor stabs have no ParentCellId so cellScope=0 → the landblock-wide grid). ShadowObjectRegistry.GetNearbyObjects (ShadowObjectRegistry.cs:434+) is a landblock+8-adjacent radial sweep patched with portalReachableCells iteration and the #98 indoor-primary early-return (:496) — and its own doc comments (:398-431) narrate the cottage GfxObj as "landblock-wide ... registered with cellScope=0" and cite the #98/#99 saga. The cellScope mechanism exists but does NOT cover building shells (they register at scope 0), so the "landblock-wide" characterization is accurate for buildings specifically.
JUDGMENT: both sides check out; the divergence is real, not behaviorally-equivalent-elsewhere — acdream's BuildingPhysics carries only portal data for membership (CheckBuildingTransit), while collision/get_object never route through a per-cell building reference; that is the documented A6.P4 debt behind #99 (per the physics digest and the in-code #98/#99 commentary). The missing >= 0 gate is a genuine correctness divergence made live by the Ghidra-proven sign-extension. Severity "medium" and the proposed port shape (fold into A6.P4 per-cell shadow architecture; widen OtherPortalId to signed; add the gate) are consistent with the evidence.
CAVEATS (do not change the verdict): (a) whether any Holtburg-area building portal actually carries 0xFFFF→-1 in the dat is unverified — no portal dump exists in-tree (Issue113PhantomStairsDumpTests would print it but running tests is out of scope here), so the gate gap's practical trigger set at Holtburg may be empty; (b) in acdream a sentinel-bearing portal would often be skipped anyway by the GetCellStruct null-check if its OtherCellId doesn't resolve to a cached EnvCell — the gate gap only bites for a portal with a VALID destination cell but a negative other_portal_id. Both caveats are consistent with the claim's own "medium / subtle membership differences" framing.
- blastRadius: #99 door run-through (the known A6.P4 per-cell shadow debt — retail dispatches collision per-cell through CSortCell.building, acdream through a landblock-wide ShadowObjectRegistry); subtle membership differences at building entries: retail's check_building_transit skips portals with other_portal_id < 0 and tests sphere_intersects_cell on the structure, acdream loops every cached portal without the >=0 gate (BldPortalInfo stores OtherPortalId as ushort, so retail's -1 sentinel cannot be expressed).
- retailEvidence: CSortCell::find_transit_cells/find_collisions/get_object all route through cell->building (0x534060/0x5340a0/0x5340c0 pc:318309-318362); CEnvCell::check_building_transit gates
other_portal_id >= 0(0x52c5d0 pc:309838); building registered into its host cell at init_buildings (0x52fd80 pc:313907) and into the landblock stablist (pc:313910). - acdreamEvidence: CheckBuildingTransit iterates all cached portals with no portal-id gate, sphere vs whole CellBSP (CellTransit.cs:353-398, BldPortalInfo at BuildingPhysics.cs:25-40 with ushort OtherPortalId); shell collision via per-part ShadowObjectRegistry entries from the 0xC0 entity (GameWindow.cs:6122+); BuildingPhysics keyed by host landcell id only (PhysicsDataCache.cs:433).
- portShape: Fold into the already-planned A6.P4 per-cell shadow architecture: give the host outdoor cell a building reference and dispatch transit/collision/get_object through it; widen OtherPortalId to a signed value and add the >=0 gate; this is a correctness alignment, not a redesign.
[LOW] leaf-cells-unported (UNVERIFIED (verifier hit token limit)) — leaf_cells/CPartCell per-BSP-leaf object buckets have no acdream equivalent
- blastRadius: None demonstrated — the retail path itself appears dormant in the 2013 build (no writer of CBuildingObj::curr_leaf_cells found; Ghidra xrefs to 0x8fa9bc are reads only), and acdream's depth-buffered entity pass makes BSP-ordered interleaving unnecessary for correctness.
- retailEvidence: CBuildingObj.leaf_cells allocated num_leaves NULL slots at makeBuilding (0x6b53a0 pc:701299-701311); DrawBuildingLeaf draws leaf_cells[i] via DrawPartCell with pushLevelOffset=1 (0x5a07e0 pc:429223-429240); BuildInfo.num_leaves from the dat (acclient.h:32035).
- acdreamEvidence: No counterpart anywhere; BuildInfo.NumLeaves is read but unused beyond makeBuilding parity (LandblockLoader.cs:75-92 ignores it).
- portShape: Do not port unless a runtime trace shows the 2013 client actually populating leaf cells; file the open question instead.
OPEN QUESTIONS
- Where (if anywhere) does retail draw the TEXTURED surface of a building portal poly? The portal polys sit in the same CGfxObj.polygons array that D3DPolyRender::ConstructMesh consumes (0x59ea90 pc:427540-427543; BSPPORTAL::UnPackPortal points in_portals into pack_poly, 0x53db70 pc:327256), yet always drawing them would seal every aperture against the flood — so an exclusion must exist (ConstructMesh per-poly filter? a stippling bit on GfxObj portal polys that DatReaderWriter maps differently than the cell-struct NoPos case? exclusion at CGfxObj unpack?). The only confirmed portal-poly draw (DrawPortalPolyInternal, Ghidra 0x59bc90) is alpha=0 invisible. Resolve by Ghidra-reading ConstructMesh's poly loop (0x59dfa0, the section past 0x59e132) and dumping the dat Stippling/SidesType bits of a known Holtburg door-fill quad. This decides whether acdream's flood-fail case should draw the textured quad or nothing+seal — the
e223325inference that 'doors are usually visible' in retail may actually be the separate swinging-door ENTITY, not the baked quad. - Who calls RenderDeviceD3D::DrawBuildingLeaf and who writes CBuildingObj::curr_leaf_cells/curr_num_leaves? Ghidra xrefs to 0x8fa9bc show reads only (DrawBuildingLeaf), and no named writer exists in the 1.4M-line pseudo-C — the building leaf-cell render path looks dormant in the 2013 build, but a vtable-indirect or memcpy-style writer could have been missed. Settle with a cdb breakpoint on DrawBuildingLeaf at a Holtburg building before deciding the port scope.
- Exact semantics of PView::DrawCells' Clear(4, 0x820fc0, 1.0) (pc:432730-432734) — which buffers flag 4 clears (depth assumed from the 1.0 z argument) and what forceClear is for; matters for ordering parity when porting the punch/seal machinery.
- The building_view latch under NESTED floods (building seen through a doorway which floods another building) was traced statically (save/-1/restore at 0x59f0e0 pc:427906-427914, slot bind at 0x5a08c0-0x5a08cd) but never runtime-verified; a cdb trace logging (portal_view_num, building_view) per DrawPortal would confirm the multi-slot binding before porting it.
- Do window-type CBldPortals flood in retail (do they carry stab lists and a GetVisible-able other_cell_id with a passable portal_side), i.e. are cottage windows see-through to the interior in retail or permanently failed views? Determines whether window aperture quads need different treatment from door quads in the port.
- CBldPortal.sidedness (float, acclient.h:32101) is read by none of the functions examined this session — likely a runtime-computed plane-side cache, but unverified; check before assuming it can be dropped from the port.